From 4d5f3bfb06c8bf43943f48a95af3a1b6b5bb1ee4 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:20:46 -0800 Subject: [PATCH 01/11] feat: Bring TB up to date with design changes Make the fetching async and non blocking. Provide a method for manual override. Implement proactive refresh every 6 hours. Implement automatic recovery if api request fails due to stale regional boundary. Remove no-op signal and checks. Refactor to Regional Access Boundary name. --- google/auth/_constants.py | 6 +- .../auth/_regional_access_boundary_utils.py | 87 +++ google/auth/aws.py | 40 +- google/auth/compute_engine/credentials.py | 78 +-- google/auth/credentials.py | 337 +++++++--- google/auth/environment_vars.py | 6 +- google/auth/external_account.py | 60 +- .../auth/external_account_authorized_user.py | 42 +- google/auth/impersonated_credentials.py | 46 +- google/auth/transport/requests.py | 33 + google/oauth2/_client.py | 39 +- google/oauth2/service_account.py | 43 +- tests/compute_engine/test_credentials.py | 296 ++------- tests/oauth2/test__client.py | 88 +-- tests/oauth2/test_service_account.py | 257 ++------ tests/test_aws.py | 575 ++---------------- tests/test_credentials.py | 258 +++++--- tests/test_external_account.py | 284 ++------- .../test_external_account_authorized_user.py | 57 +- tests/test_identity_pool.py | 7 - tests/test_impersonated_credentials.py | 325 +++------- tests/test_pluggable.py | 12 +- tests/transport/test_requests.py | 50 ++ 23 files changed, 1097 insertions(+), 1929 deletions(-) create mode 100644 google/auth/_regional_access_boundary_utils.py diff --git a/google/auth/_constants.py b/google/auth/_constants.py index 28e47025f..9f457bbc6 100644 --- a/google/auth/_constants.py +++ b/google/auth/_constants.py @@ -1,5 +1,5 @@ """Shared constants.""" -_SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations" -_WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations" -_WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations" +_SERVICE_ACCOUNT_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations" +_WORKFORCE_POOL_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations" +_WORKLOAD_IDENTITY_POOL_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations" diff --git a/google/auth/_regional_access_boundary_utils.py b/google/auth/_regional_access_boundary_utils.py new file mode 100644 index 000000000..d4f52b9dc --- /dev/null +++ b/google/auth/_regional_access_boundary_utils.py @@ -0,0 +1,87 @@ +"""Utilities for Regional Access Boundary management.""" + +import threading +import datetime + +from google.auth import _helpers +from google.auth import exceptions +from google.auth._default import _LOGGER + + +# The default lifetime for a cached Regional Access Boundary. +DEFAULT_REGIONAL_ACCESS_BOUNDARY_TTL = datetime.timedelta(hours=6) + +# The initial cooldown period for a failed Regional Access Boundary lookup. +DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN = datetime.timedelta(minutes=15) + + +class _RegionalAccessBoundaryRefreshThread(threading.Thread): + """Thread for background refreshing of the Regional Access Boundary.""" + + def __init__(self, credentials, request): + super(_RegionalAccessBoundaryRefreshThread, self).__init__() + self._credentials = credentials + self._request = request + + def run(self): + """ + Performs the Regional Access Boundary lookup. This method is run in a separate thread. + + It includes a short-term retry loop for transient server errors. If the + lookup fails completely, it sets a longer-term cooldown period on the + credential to avoid overwhelming the lookup service. + """ + regional_access_boundary_info = self._credentials._lookup_regional_access_boundary_with_retry( + self._request + ) + + if regional_access_boundary_info: + # On success, update the boundary and its expiry, and clear any cooldown. + self._credentials._regional_access_boundary = regional_access_boundary_info + self._credentials._regional_access_boundary_expiry = ( + _helpers.utcnow() + DEFAULT_REGIONAL_ACCESS_BOUNDARY_TTL + ) + self._credentials._regional_access_boundary_cooldown_expiry = None + if _helpers.is_logging_enabled(_LOGGER): + _LOGGER.debug( + "Asynchronous Regional Access Boundary lookup successful." + ) + else: + # On complete failure, set a cooldown period. The existing + # _regional_access_boundary and _regional_access_boundary_expiry + # will be kept as they are considered safe to use until explicitly + # invalidated by a "stale Regional Access Boundary" API error. + if _helpers.is_logging_enabled(_LOGGER): + _LOGGER.warning( + "Asynchronous Regional Access Boundary lookup failed. Entering cooldown." + ) + + self._credentials._regional_access_boundary_cooldown_expiry = ( + _helpers.utcnow() + DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN + ) + + +class _RegionalAccessBoundaryRefreshManager(object): + """Manages a thread for background refreshing of the Regional Access Boundary.""" + + def __init__(self): + self._lock = threading.Lock() + self._worker = None + + def start_refresh(self, credentials, request): + """ + Starts a background thread to refresh the Regional Access Boundary if one is not already running. + + Args: + credentials (CredentialsWithRegionalAccessBoundary): The credentials + to refresh. + request (google.auth.transport.Request): The object used to make + HTTP requests. + """ + with self._lock: + if self._worker and self._worker.is_alive(): + # A refresh is already in progress. + return + + self._worker = _RegionalAccessBoundaryRefreshThread(credentials, request) + self._worker.start() diff --git a/google/auth/aws.py b/google/auth/aws.py index 28c065d3c..b4e5365e9 100644 --- a/google/auth/aws.py +++ b/google/auth/aws.py @@ -273,9 +273,9 @@ def _generate_authentication_header_map( full_headers[key.lower()] = additional_headers[key] # Add AWS session token if available. if aws_security_credentials.session_token is not None: - full_headers[ - _AWS_SECURITY_TOKEN_HEADER - ] = aws_security_credentials.session_token + full_headers[_AWS_SECURITY_TOKEN_HEADER] = ( + aws_security_credentials.session_token + ) # Required headers full_headers["host"] = host @@ -348,10 +348,10 @@ def _generate_authentication_header_map( class AwsSecurityCredentials: """A class that models AWS security credentials with an optional session token. - Attributes: - access_key_id (str): The AWS security credentials access key id. - secret_access_key (str): The AWS security credentials secret access key. - session_token (Optional[str]): The optional AWS security credentials session token. This should be set when using temporary credentials. + Attributes: + access_key_id (str): The AWS security credentials access key id. + secret_access_key (str): The AWS security credentials secret access key. + session_token (Optional[str]): The optional AWS security credentials session token. This should be set when using temporary credentials. """ access_key_id: str @@ -641,7 +641,7 @@ def __init__( "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone", "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials", - imdsv2_session_token_url": "http://169.254.169.254/latest/api/token" + "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token" } aws_security_credentials_supplier (Optional [AwsSecurityCredentialsSupplier]): Optional AWS security credentials supplier. @@ -660,6 +660,9 @@ def __init__( :meth:`from_file` or :meth:`from_info` are used instead of calling the constructor directly. """ + # Pop regional_access_boundary from kwargs to avoid passing it to the parent constructor. + kwargs.pop("regional_access_boundary", None) + super(Credentials, self).__init__( audience=audience, subject_token_type=subject_token_type, @@ -688,8 +691,8 @@ def __init__( ) else: environment_id = credential_source.get("environment_id") or "" - self._aws_security_credentials_supplier = _DefaultAwsSecurityCredentialsSupplier( - credential_source + self._aws_security_credentials_supplier = ( + _DefaultAwsSecurityCredentialsSupplier(credential_source) ) self._cred_verification_url = credential_source.get( "regional_cred_verification_url" @@ -759,8 +762,10 @@ def retrieve_subject_token(self, request): # Retrieve the AWS security credentials needed to generate the signed # request. - aws_security_credentials = self._aws_security_credentials_supplier.get_aws_security_credentials( - self._supplier_context, request + aws_security_credentials = ( + self._aws_security_credentials_supplier.get_aws_security_credentials( + self._supplier_context, request + ) ) # Generate the signed request to AWS STS GetCallerIdentity API. # Use the required regional endpoint. Otherwise, the request will fail. @@ -845,7 +850,16 @@ def from_info(cls, info, **kwargs): kwargs.update( {"aws_security_credentials_supplier": aws_security_credentials_supplier} ) - return super(Credentials, cls).from_info(info, **kwargs) + regional_access_boundary = info.pop("regional_access_boundary", None) + + credentials = super(Credentials, cls).from_info(info, **kwargs) + + if regional_access_boundary: + credentials = credentials.with_regional_access_boundary( + regional_access_boundary + ) + + return credentials @classmethod def from_file(cls, filename, **kwargs): diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 0f518166a..1bc57f92e 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -21,6 +21,7 @@ import datetime +import warnings from google.auth import _helpers from google.auth import credentials from google.auth import exceptions @@ -30,7 +31,7 @@ from google.auth.compute_engine import _metadata from google.oauth2 import _client -_TRUST_BOUNDARY_LOOKUP_ENDPOINT = ( +_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT = ( "https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations" ) @@ -39,7 +40,7 @@ class Credentials( credentials.Scoped, credentials.CredentialsWithQuotaProject, credentials.CredentialsWithUniverseDomain, - credentials.CredentialsWithTrustBoundary, + credentials.CredentialsWithRegionalAccessBoundary, ): """Compute Engine Credentials. @@ -66,7 +67,7 @@ def __init__( scopes=None, default_scopes=None, universe_domain=None, - trust_boundary=None, + regional_access_boundary=None, ): """ Args: @@ -82,7 +83,7 @@ def __init__( provided or None, credential will attempt to fetch the value from metadata server. If metadata server doesn't have universe domain endpoint, then the default googleapis.com will be used. - trust_boundary (Mapping[str,str]): A credential trust boundary. + regional_access_boundary (Mapping[str,str]): A credential Regional Access Boundary. """ super(Credentials, self).__init__() self._service_account_email = service_account_email @@ -93,7 +94,6 @@ def __init__( if universe_domain: self._universe_domain = universe_domain self._universe_domain_cached = True - self._trust_boundary = trust_boundary def _retrieve_info(self, request): """Retrieve information about the service account. @@ -146,8 +146,8 @@ def _refresh_token(self, request): new_exc = exceptions.RefreshError(caught_exc) raise new_exc from caught_exc - def _build_trust_boundary_lookup_url(self): - """Builds and returns the URL for the trust boundary lookup API for GCE.""" + def _build_regional_access_boundary_lookup_url(self): + """Builds and returns the URL for the Regional Access Boundary lookup API for GCE.""" # If the service account email is 'default', we need to get the # actual email address from the metadata server. if self._service_account_email == "default": @@ -165,15 +165,15 @@ def _build_trust_boundary_lookup_url(self): except exceptions.TransportError as e: # If fetching the service account email fails due to a transport error, - # it means we cannot build the trust boundary lookup URL. - # Wrap this in a RefreshError so it's caught by _refresh_trust_boundary. + # it means we cannot build the Regional Access Boundary lookup URL. + # Wrap this in a RefreshError so it's caught by _refresh_regional_access_boundary. raise exceptions.RefreshError( - "Failed to get service account email for trust boundary lookup: {}".format( + "Failed to get service account email for Regional Access Boundary lookup: {}".format( e ) ) from e - return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + return _REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( self.universe_domain, self.service_account_email ) @@ -211,57 +211,37 @@ def get_cred_info(self): "principal": self.service_account_email, } - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - creds = self.__class__( + def _make_copy(self): + """Create a copy of the current credentials.""" + new_creds = self.__class__( service_account_email=self._service_account_email, - quota_project_id=quota_project_id, + quota_project_id=self._quota_project_id, scopes=self._scopes, default_scopes=self._default_scopes, universe_domain=self._universe_domain, - trust_boundary=self._trust_boundary, ) - creds._universe_domain_cached = self._universe_domain_cached + new_creds._universe_domain_cached = self._universe_domain_cached + self._copy_regional_access_boundary_state(new_creds) + return new_creds + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + creds = self._make_copy() + creds._quota_project_id = quota_project_id return creds @_helpers.copy_docstring(credentials.Scoped) def with_scopes(self, scopes, default_scopes=None): - # Compute Engine credentials can not be scoped (the metadata service - # ignores the scopes parameter). App Engine, Cloud Run and Flex support - # requesting scopes. - creds = self.__class__( - scopes=scopes, - default_scopes=default_scopes, - service_account_email=self._service_account_email, - quota_project_id=self._quota_project_id, - universe_domain=self._universe_domain, - trust_boundary=self._trust_boundary, - ) - creds._universe_domain_cached = self._universe_domain_cached + creds = self._make_copy() + creds._scopes = scopes + creds._default_scopes = default_scopes return creds @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - return self.__class__( - scopes=self._scopes, - default_scopes=self._default_scopes, - service_account_email=self._service_account_email, - quota_project_id=self._quota_project_id, - trust_boundary=self._trust_boundary, - universe_domain=universe_domain, - ) - - @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) - def with_trust_boundary(self, trust_boundary): - creds = self.__class__( - service_account_email=self._service_account_email, - quota_project_id=self._quota_project_id, - scopes=self._scopes, - default_scopes=self._default_scopes, - universe_domain=self._universe_domain, - trust_boundary=trust_boundary, - ) - creds._universe_domain_cached = self._universe_domain_cached + creds = self._make_copy() + creds._universe_domain = universe_domain + creds._universe_domain_cached = True return creds diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 82c73c3bf..3505b4244 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -19,17 +19,22 @@ from enum import Enum import os from typing import List +import warnings +from urllib.parse import urlparse +import datetime +import threading from google.auth import _helpers, environment_vars from google.auth import exceptions from google.auth import metrics +from google.auth import _exponential_backoff from google.auth._credentials_base import _BaseCredentials from google.auth._default import _LOGGER from google.auth._refresh_worker import RefreshThreadManager +from google.auth import _regional_access_boundary_utils DEFAULT_UNIVERSE_DOMAIN = "googleapis.com" -NO_OP_TRUST_BOUNDARY_LOCATIONS: List[str] = [] -NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS = "0x0" +_REGIONAL_ACCESS_BOUNDARY_RETRYABLE_STATUS_CODES = (403, 404, 500, 502, 503, 504) class Credentials(_BaseCredentials): @@ -288,8 +293,18 @@ def with_universe_domain(self, universe_domain): ) -class CredentialsWithTrustBoundary(Credentials): - """Abstract base for credentials supporting ``with_trust_boundary`` factory""" +class CredentialsWithRegionalAccessBoundary(Credentials): + """Abstract base for credentials supporting ``with_regional_access_boundary`` factory""" + + def __init__(self, *args, **kwargs): + super(CredentialsWithRegionalAccessBoundary, self).__init__(*args, **kwargs) + self._regional_access_boundary = None + self._regional_access_boundary_expiry = None + self._regional_access_boundary_cooldown_expiry = None + self._regional_access_boundary_refresh_manager = ( + _regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager() + ) + self._stale_boundary_lock = threading.Lock() @abc.abstractmethod def _refresh_token(self, request): @@ -305,142 +320,314 @@ def _refresh_token(self, request): """ raise NotImplementedError("_refresh_token must be implemented") - def with_trust_boundary(self, trust_boundary): - """Returns a copy of these credentials with a modified trust boundary. + def with_regional_access_boundary( + self, regional_access_boundary, enable_proactive_refresh=True + ): + """Returns a copy of these credentials with a modified Regional Access Boundary. + + This method allows for manually providing the Regional Access Boundary + information, bypassing the asynchronous lookup. It also supports + enabling or disabling the proactive refresh of this data. Args: - trust_boundary Mapping[str, str]: The trust boundary to use for the - credential. This should be a map with a "locations" key that maps to - a list of GCP regions, and a "encodedLocations" key that maps to a - hex string. + regional_access_boundary (Mapping[str, str]): The Regional Access Boundary + to use for the credential. This should be a map with an + "encodedLocations" key that maps to a hex string. Optionally, + it can also contain a "locations" key with a list of GCP regions. + Example: `{"locations": ["us-central1"], "encodedLocations": "0xA30"}` + enable_proactive_refresh (bool): If `True` (the default), the library + will treat the provided boundary as having a 6-hour lifetime and + will attempt to refresh it asynchronously before it expires. If + `False`, the proactive refresh will be disabled, and the provided + boundary will be considered valid indefinitely until an API call + fails with a "stale Regional Access Boundary" error. Returns: - google.auth.credentials.Credentials: A new credentials instance. + google.auth.credentials.Credentials: A new credentials instance + with the specified Regional Access Boundary. + + Raises: + google.auth.exceptions.InvalidValue: If `regional_access_boundary` + is not a dictionary or does not contain the "encodedLocations" key. + """ + if ( + not isinstance(regional_access_boundary, dict) + or "encodedLocations" not in regional_access_boundary + ): + raise exceptions.InvalidValue( + "regional_access_boundary must be a dictionary with an 'encodedLocations' key." + ) + + new_creds = self._make_copy() + new_creds._regional_access_boundary = regional_access_boundary + + if enable_proactive_refresh: + new_creds._regional_access_boundary_expiry = ( + _helpers.utcnow() + + _regional_access_boundary_utils.DEFAULT_REGIONAL_ACCESS_BOUNDARY_TTL + ) + else: + new_creds._regional_access_boundary_expiry = None + + new_creds._regional_access_boundary_cooldown_expiry = None + + return new_creds + + def _copy_regional_access_boundary_state(self, target): + """Copies the regional access boundary state to another instance.""" + target._regional_access_boundary = self._regional_access_boundary + target._regional_access_boundary_expiry = self._regional_access_boundary_expiry + target._regional_access_boundary_cooldown_expiry = ( + self._regional_access_boundary_cooldown_expiry + ) + target._stale_boundary_lock = self._stale_boundary_lock + + def handle_stale_regional_access_boundary(self, request): + """Handles a stale regional access boundary error. + This method is thread-safe and will only initiate a single refresh + even if called concurrently. + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + """ + with self._stale_boundary_lock: + # Another thread might have already handled the stale boundary. + if self._regional_access_boundary is None: + return + + _LOGGER.info("Stale regional access boundary detected. Refreshing.") + + # Clear the cached boundary. + self._regional_access_boundary = None + self._regional_access_boundary_expiry = None + + # Start the background refresh. + self._regional_access_boundary_refresh_manager.start_refresh(self, request) + + + def with_trust_boundary(self, trust_boundary): + """Deprecated. Use with_regional_access_boundary instead.""" + warnings.warn( + "'with_trust_boundary' is deprecated and will be removed in a future version. Please use 'with_regional_access_boundary'.", + DeprecationWarning, + stacklevel=2, + ) + return self.with_regional_access_boundary(trust_boundary) + + def _maybe_start_regional_access_boundary_refresh(self, request, url): + """ + Starts a background thread to refresh the Regional Access Boundary if needed. + + This method checks if a refresh is necessary and if one is not already + in progress or in a cooldown period. If so, it starts a background + thread to perform the lookup. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + url (str): The URL of the request. """ - raise NotImplementedError("This credential does not support trust boundaries.") + try: + # Do not perform a lookup if the request is for a regional endpoint. + hostname = urlparse(url).hostname + if hostname and ( + hostname.endswith(".rep.googleapis.com") + or hostname.endswith(".rep.sandbox.googleapis.com") + ): + return + except (ValueError, TypeError): + # If the URL is malformed, proceed with the default lookup behavior. + pass + + # A refresh is needed only if the feature is enabled and Regional Access Boundary is not set. + if ( + not self._is_regional_access_boundary_lookup_required() + or self._regional_access_boundary + ): + return + + # Don't start a new refresh if the Regional Access Boundary info is still valid. + if ( + self._regional_access_boundary_expiry + and _helpers.utcnow() < self._regional_access_boundary_expiry + ): + return + + # Don't start a new refresh if the cooldown is still in effect. + if ( + self._regional_access_boundary_cooldown_expiry + and _helpers.utcnow() < self._regional_access_boundary_cooldown_expiry + ): + return + + # If all checks pass, start the background refresh. + self._regional_access_boundary_refresh_manager.start_refresh(self, request) - def _is_trust_boundary_lookup_required(self): - """Checks if a trust boundary lookup is required. + def _is_regional_access_boundary_lookup_required(self): + """Checks if a Regional Access Boundary lookup is required. A lookup is required if the feature is enabled via an environment - variable, the universe domain is supported, and a no-op boundary - is not already cached. + variable and the universe domain is supported. Returns: - bool: True if a trust boundary lookup is required, False otherwise. + bool: True if a Regional Access Boundary lookup is required, False otherwise. """ - # 1. Check if the feature is enabled via environment variable. - if not _helpers.get_bool_from_env( - environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED, default=False - ): + # 1. Check environment variables to see if the feature is enabled. + # The new Regional Access Boundary variable is checked first. + new_env_var = os.environ.get( + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT + ) + if new_env_var is not None: + enabled = new_env_var.lower() in ("true", "1") + else: + # Fallback to the old deprecated variable. + old_env_var = os.environ.get("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED") + if old_env_var is not None: + warnings.warn( + "'GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED' is deprecated and will be removed in a future version. Please use 'GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT'.", + DeprecationWarning, + stacklevel=2, + ) + enabled = old_env_var.lower() in ("true", "1") + else: + enabled = False + + # If not enabled, no need to proceed. + if not enabled: return False - # 2. Skip trust boundary flow for non-default universe domains. + # 2. Skip for non-default universe domains. if self.universe_domain != DEFAULT_UNIVERSE_DOMAIN: return False - # 3. Do not trigger refresh if credential has a cached no-op trust boundary. - return not self._has_no_op_trust_boundary() + return True - def _get_trust_boundary_header(self): - if self._trust_boundary is not None: - if self._has_no_op_trust_boundary(): - # STS expects an empty string if the trust boundary value is no-op. - return {"x-allowed-locations": ""} - else: - return {"x-allowed-locations": self._trust_boundary["encodedLocations"]} + def _get_regional_access_boundary_header(self): + if self._regional_access_boundary is not None: + return { + "x-allowed-locations": self._regional_access_boundary[ + "encodedLocations" + ] + } return {} def apply(self, headers, token=None): """Apply the token to the authentication header.""" super().apply(headers, token) - headers.update(self._get_trust_boundary_header()) + + boundary_header = self._get_regional_access_boundary_header() + if boundary_header: + headers.update(boundary_header) + else: + # If we have no boundary to add, ensure the header is not present + # from a previous, stale state. We use pop() with a default to + # avoid a KeyError if the header was never there. + headers.pop("x-allowed-locations", None) + + def before_request(self, request, method, url, headers): + """Refreshes the access token and triggers the Regional Access Boundary + lookup if necessary. + """ + super(CredentialsWithRegionalAccessBoundary, self).before_request( + request, method, url, headers + ) + self._maybe_start_regional_access_boundary_refresh(request, url) def refresh(self, request): - """Refreshes the access token and the trust boundary. + """Refreshes the access token. - This method calls the subclass's token refresh logic and then - refreshes the trust boundary if applicable. + This method calls the subclass's token refresh logic. The Regional + Access Boundary is refreshed separately in a non-blocking way. """ self._refresh_token(request) - self._refresh_trust_boundary(request) - def _refresh_trust_boundary(self, request): - """Triggers a refresh of the trust boundary and updates the cache if necessary. + def _lookup_regional_access_boundary_with_retry(self, request): + """ + Calls the regional access boundary lookup endpoint with a retry loop + for transient errors. Args: request (google.auth.transport.Request): The object used to make HTTP requests. - Raises: - google.auth.exceptions.RefreshError: If the trust boundary could - not be refreshed and no cached value is available. + Returns: + Optional[dict]: The regional access boundary information returned by the + lookup API, or None if the lookup fails. """ - if not self._is_trust_boundary_lookup_required(): - return - try: - self._trust_boundary = self._lookup_trust_boundary(request) - except exceptions.RefreshError as error: - # If the call to the lookup API failed, check if there is a trust boundary - # already cached. If there is, do nothing. If not, then throw the error. - if self._trust_boundary is None: - raise error - if _helpers.is_logging_enabled(_LOGGER): - _LOGGER.debug( - "Using cached trust boundary due to refresh error: %s", error + retries = _exponential_backoff.ExponentialBackoff(total_attempts=6) + last_error = None + for _ in retries: + try: + regional_access_boundary_response = self._lookup_regional_access_boundary( + request ) - return + return regional_access_boundary_response + except exceptions.RefreshError as caught_exc: + last_error = caught_exc + # Retry only on specific HTTP errors indicating transient issues + if hasattr(caught_exc, "response") and caught_exc.response is not None: + status_code = caught_exc.response.status + if status_code in _REGIONAL_ACCESS_BOUNDARY_RETRYABLE_STATUS_CODES: + _LOGGER.debug( + "Regional access boundary lookup failed with retryable error " + "%s. Retrying...", + caught_exc, + ) + continue # Retry on transient errors + # Non-retryable error or no status code, break the loop. + break + # If all retries are exhausted, log a warning and return None. + _LOGGER.warning( + "Regional access boundary lookup failed after retries: %s", last_error + ) + return None - def _lookup_trust_boundary(self, request): - """Calls the trust boundary lookup API to refresh the trust boundary cache. + def _lookup_regional_access_boundary(self, request): + """Calls the Regional Access Boundary lookup API to refresh the Regional Access Boundary cache. Args: request (google.auth.transport.Request): The object used to make HTTP requests. Returns: - trust_boundary (dict): The trust boundary object returned by the lookup API. + dict: The Regional Access Boundary object returned by the lookup API. Raises: - google.auth.exceptions.RefreshError: If the trust boundary could not be + google.auth.exceptions.RefreshError: If the Regional Access Boundary could not be retrieved. """ from google.oauth2 import _client - url = self._build_trust_boundary_lookup_url() + url = self._build_regional_access_boundary_lookup_url() if not url: - raise exceptions.InvalidValue("Failed to build trust boundary lookup URL.") + raise exceptions.InvalidValue( + "Failed to build Regional Access Boundary lookup URL." + ) headers = {} self._apply(headers) - headers.update(self._get_trust_boundary_header()) - return _client._lookup_trust_boundary(request, url, headers=headers) + headers.update(self._get_regional_access_boundary_header()) + return _client._lookup_regional_access_boundary(request, url, headers=headers) @abc.abstractmethod - def _build_trust_boundary_lookup_url(self): + def _build_regional_access_boundary_lookup_url(self): """ - Builds and returns the URL for the trust boundary lookup API. + Builds and returns the URL for the Regional Access Boundary lookup API. This method should be implemented by subclasses to provide the specific URL based on the credential type and its properties. Returns: - str: The URL for the trust boundary lookup endpoint, or None + str: The URL for the Regional Access Boundary lookup endpoint, or None if lookup should be skipped (e.g., for non-applicable universe domains). """ raise NotImplementedError( - "_build_trust_boundary_lookup_url must be implemented" + "_build_regional_access_boundary_lookup_url must be implemented" ) - def _has_no_op_trust_boundary(self): - # A no-op trust boundary is indicated by encodedLocations being "0x0". - # The "locations" list may or may not be present as an empty list. - if self._trust_boundary is None: - return False - return ( - self._trust_boundary.get("encodedLocations") - == NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS - ) + +# For backward compatibility. +CredentialsWithTrustBoundary = CredentialsWithRegionalAccessBoundary class AnonymousCredentials(Credentials): diff --git a/google/auth/environment_vars.py b/google/auth/environment_vars.py index 5da3a7382..ddab4bf0f 100644 --- a/google/auth/environment_vars.py +++ b/google/auth/environment_vars.py @@ -89,6 +89,8 @@ AWS_REGION = "AWS_REGION" AWS_DEFAULT_REGION = "AWS_DEFAULT_REGION" -GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED" -"""Environment variable controlling whether to enable trust boundary feature. +GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT = ( + "GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT" +) +"""Environment variable controlling whether to enable Regional Access Boundary feature. The default value is false. Users have to explicitly set this value to true.""" diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 8eba0d249..47c4b5e19 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -35,6 +35,7 @@ import io import json import re +import warnings from google.auth import _constants from google.auth import _helpers @@ -82,7 +83,7 @@ class Credentials( credentials.Scoped, credentials.CredentialsWithQuotaProject, credentials.CredentialsWithTokenUri, - credentials.CredentialsWithTrustBoundary, + credentials.CredentialsWithRegionalAccessBoundary, metaclass=abc.ABCMeta, ): """Base class for all external account credentials. @@ -116,7 +117,6 @@ def __init__( default_scopes=None, workforce_pool_user_project=None, universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ): """Instantiates an external account credentials object. @@ -149,7 +149,6 @@ def __init__( billing/quota. universe_domain (str): The universe domain. The default universe domain is googleapis.com. - trust_boundary (str): String representation of trust boundary meta. Raises: google.auth.exceptions.RefreshError: If the generateAccessToken endpoint returned an error. @@ -175,7 +174,6 @@ def __init__( self._scopes = scopes self._default_scopes = default_scopes self._workforce_pool_user_project = workforce_pool_user_project - self._trust_boundary = trust_boundary if self._client_id: self._client_auth = utils.ClientAuthentication( @@ -241,7 +239,6 @@ def _constructor_args(self): "scopes": self._scopes, "default_scopes": self._default_scopes, "universe_domain": self._universe_domain, - "trust_boundary": self._trust_boundary, } if not self.is_workforce_pool: args.pop("workforce_pool_user_project") @@ -416,17 +413,9 @@ def refresh(self, request): """Refreshes the access token. For impersonated credentials, this method will refresh the underlying - source credentials and the impersonated credentials. For non-impersonated - credentials, it will refresh the access token and the trust boundary. + source credentials and the impersonated credentials. """ self._refresh_token(request) - # If we are impersonating, the trust boundary is handled by the - # impersonated credentials object. We need to get it from there. - if self._service_account_impersonation_url: - self._trust_boundary = self._impersonated_credentials._trust_boundary - else: - # Otherwise, refresh the trust boundary for the external account. - self._refresh_trust_boundary(request) def _refresh_token(self, request): scopes = self._scopes if self._scopes is not None else self._default_scopes @@ -478,8 +467,8 @@ def _refresh_token(self, request): self.expiry = now + lifetime - def _build_trust_boundary_lookup_url(self): - """Builds and returns the URL for the trust boundary lookup API.""" + def _build_regional_access_boundary_lookup_url(self): + """Builds and returns the URL for the Regional Access Boundary lookup API.""" url = None # Try to parse as a workload identity pool. # Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID @@ -489,7 +478,7 @@ def _build_trust_boundary_lookup_url(self): ) if workload_match: project_number, pool_id = workload_match.groups() - url = _constants._WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + url = _constants._WORKLOAD_IDENTITY_POOL_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( universe_domain=self._universe_domain, project_number=project_number, pool_id=pool_id, @@ -502,7 +491,7 @@ def _build_trust_boundary_lookup_url(self): ) if workforce_match: pool_id = workforce_match.groups()[0] - url = _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + url = _constants._WORKFORCE_POOL_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( universe_domain=self._universe_domain, pool_id=pool_id ) @@ -517,6 +506,7 @@ def _make_copy(self): new_cred = self.__class__(**kwargs) new_cred._cred_file_path = self._cred_file_path new_cred._metrics_options = self._metrics_options + self._copy_regional_access_boundary_state(new_cred) return new_cred @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) @@ -538,12 +528,6 @@ def with_universe_domain(self, universe_domain): cred._universe_domain = universe_domain return cred - @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) - def with_trust_boundary(self, trust_boundary): - cred = self._make_copy() - cred._trust_boundary = trust_boundary - return cred - def _should_initialize_impersonated_credentials(self): return ( self._service_account_impersonation_url is not None @@ -583,7 +567,7 @@ def _initialize_impersonated_credentials(self): scopes = self._scopes if self._scopes is not None else self._default_scopes # Initialize and return impersonated credentials. - return impersonated_credentials.Credentials( + impersonated_creds = impersonated_credentials.Credentials( source_credentials=source_credentials, target_principal=target_principal, target_scopes=scopes, @@ -592,8 +576,12 @@ def _initialize_impersonated_credentials(self): lifetime=self._service_account_impersonation_options.get( "token_lifetime_seconds" ), - trust_boundary=self._trust_boundary, ) + if self._regional_access_boundary: + impersonated_creds = impersonated_creds.with_regional_access_boundary( + self._regional_access_boundary + ) + return impersonated_creds def _create_default_metrics_options(self): metrics_options = {} @@ -659,7 +647,17 @@ def from_info(cls, info, **kwargs): Raises: InvalidValue: For invalid parameters. """ - return cls( + regional_access_boundary = info.get("regional_access_boundary") + if regional_access_boundary is None: + regional_access_boundary = info.get("trust_boundary") + if regional_access_boundary is not None: + warnings.warn( + "'trust_boundary' is deprecated and will be removed in a future version. Please use 'regional_access_boundary'.", + DeprecationWarning, + stacklevel=2, + ) + + initial_creds = cls( audience=info.get("audience"), subject_token_type=info.get("subject_token_type"), token_url=info.get("token_url"), @@ -679,10 +677,16 @@ def from_info(cls, info, **kwargs): universe_domain=info.get( "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN ), - trust_boundary=info.get("trust_boundary"), **kwargs ) + if regional_access_boundary: + initial_creds = initial_creds.with_regional_access_boundary( + regional_access_boundary + ) + + return initial_creds + @classmethod def from_file(cls, filename, **kwargs): """Creates a Credentials instance from an external account json file. diff --git a/google/auth/external_account_authorized_user.py b/google/auth/external_account_authorized_user.py index 2594e048f..4ae10206b 100644 --- a/google/auth/external_account_authorized_user.py +++ b/google/auth/external_account_authorized_user.py @@ -37,6 +37,7 @@ import io import json import re +import warnings from google.auth import _constants from google.auth import _helpers @@ -52,7 +53,7 @@ class Credentials( credentials.CredentialsWithQuotaProject, credentials.ReadOnlyScoped, credentials.CredentialsWithTokenUri, - credentials.CredentialsWithTrustBoundary, + credentials.CredentialsWithRegionalAccessBoundary, ): """Credentials for External Account Authorized Users. @@ -86,7 +87,6 @@ def __init__( scopes=None, quota_project_id=None, universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ): """Instantiates a external account authorized user credentials object. @@ -112,7 +112,7 @@ def __init__( create the credentials. universe_domain (Optional[str]): The universe domain. The default value is googleapis.com. - trust_boundary (Mapping[str,str]): A credential trust boundary. + regional_access_boundary (Mapping[str,str]): A credential Regional Access Boundary. Returns: google.auth.external_account_authorized_user.Credentials: The @@ -133,7 +133,6 @@ def __init__( self._scopes = scopes self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN self._cred_file_path = None - self._trust_boundary = trust_boundary if not self.valid and not self.can_refresh: raise exceptions.InvalidOperation( @@ -181,7 +180,6 @@ def constructor_args(self): "scopes": self._scopes, "quota_project_id": self._quota_project_id, "universe_domain": self._universe_domain, - "trust_boundary": self._trust_boundary, } @property @@ -307,8 +305,8 @@ def _refresh_token(self, request): if "refresh_token" in response_data: self._refresh_token_val = response_data["refresh_token"] - def _build_trust_boundary_lookup_url(self): - """Builds and returns the URL for the trust boundary lookup API.""" + def _build_regional_access_boundary_lookup_url(self): + """Builds and returns the URL for the Regional Access Boundary lookup API.""" # Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID match = re.search(r"locations/[^/]+/workforcePools/([^/]+)", self._audience) @@ -317,7 +315,7 @@ def _build_trust_boundary_lookup_url(self): pool_id = match.groups()[0] - return _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + return _constants._WORKFORCE_POOL_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( universe_domain=self._universe_domain, pool_id=pool_id ) @@ -358,6 +356,7 @@ def _make_copy(self): kwargs = self.constructor_args() cred = self.__class__(**kwargs) cred._cred_file_path = self._cred_file_path + self._copy_regional_access_boundary_state(cred) return cred @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) @@ -378,12 +377,6 @@ def with_universe_domain(self, universe_domain): cred._universe_domain = universe_domain return cred - @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) - def with_trust_boundary(self, trust_boundary): - cred = self._make_copy() - cred._trust_boundary = trust_boundary - return cred - @classmethod def from_info(cls, info, **kwargs): """Creates a Credentials instance from parsed external account info. @@ -413,7 +406,18 @@ def from_info(cls, info, **kwargs): expiry = datetime.datetime.strptime( expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S" ) - return cls( + + regional_access_boundary = info.get("regional_access_boundary") + if regional_access_boundary is None: + regional_access_boundary = info.get("trust_boundary") + if regional_access_boundary is not None: + warnings.warn( + "'trust_boundary' is deprecated and will be removed in a future version. Please use 'regional_access_boundary'.", + DeprecationWarning, + stacklevel=2, + ) + + initial_creds = cls( audience=info.get("audience"), refresh_token=info.get("refresh_token"), token_url=info.get("token_url"), @@ -428,10 +432,16 @@ def from_info(cls, info, **kwargs): universe_domain=info.get( "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN ), - trust_boundary=info.get("trust_boundary"), **kwargs ) + if regional_access_boundary: + initial_creds = initial_creds.with_regional_access_boundary( + regional_access_boundary + ) + + return initial_creds + @classmethod def from_file(cls, filename, **kwargs): """Creates a Credentials instance from an external account json file. diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 334573428..281888a33 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -30,6 +30,7 @@ from datetime import datetime import http.client as http_client import json +import warnings from google.auth import _exponential_backoff from google.auth import _helpers @@ -46,7 +47,7 @@ _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" -_TRUST_BOUNDARY_LOOKUP_ENDPOINT = ( +_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT = ( "https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations" ) @@ -123,7 +124,7 @@ class Credentials( credentials.Scoped, credentials.CredentialsWithQuotaProject, credentials.Signing, - credentials.CredentialsWithTrustBoundary, + credentials.CredentialsWithRegionalAccessBoundary, ): """This module defines impersonated credentials which are essentially impersonated identities. @@ -204,7 +205,6 @@ def __init__( lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, quota_project_id=None, iam_endpoint_override=None, - trust_boundary=None, ): """ Args: @@ -235,7 +235,6 @@ def __init__( subject (Optional[str]): sub field of a JWT. This field should only be set if you wish to impersonate as a user. This feature is useful when using domain wide delegation. - trust_boundary (Mapping[str,str]): A credential trust boundary. """ super(Credentials, self).__init__() @@ -267,7 +266,6 @@ def __init__( self._quota_project_id = quota_project_id self._iam_endpoint_override = iam_endpoint_override self._cred_file_path = None - self._trust_boundary = trust_boundary def _metric_header_for_usage(self): return metrics.CRED_TYPE_SA_IMPERSONATE @@ -344,8 +342,8 @@ def _refresh_token(self, request): iam_endpoint_override=self._iam_endpoint_override, ) - def _build_trust_boundary_lookup_url(self): - """Builds and returns the URL for the trust boundary lookup API. + def _build_regional_access_boundary_lookup_url(self): + """Builds and returns the URL for the Regional Access Boundary lookup API. This method constructs the specific URL for the IAM Credentials API's `allowedLocations` endpoint, using the credential's universe domain @@ -356,13 +354,13 @@ def _build_trust_boundary_lookup_url(self): string, as it's required to form the URL. Returns: - str: The URL for the trust boundary lookup endpoint. + str: The URL for the Regional Access Boundary lookup endpoint. """ if not self.service_account_email: raise ValueError( - "Service account email is required to build the trust boundary lookup URL." + "Service account email is required to build the Regional Access Boundary lookup URL." ) - return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + return _REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( self.universe_domain, self.service_account_email ) @@ -435,15 +433,9 @@ def _make_copy(self): lifetime=self._lifetime, quota_project_id=self._quota_project_id, iam_endpoint_override=self._iam_endpoint_override, - trust_boundary=self._trust_boundary, ) cred._cred_file_path = self._cred_file_path - return cred - - @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) - def with_trust_boundary(self, trust_boundary): - cred = self._make_copy() - cred._trust_boundary = trust_boundary + self._copy_regional_access_boundary_state(cred) return cred @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) @@ -527,17 +519,31 @@ def from_impersonated_service_account_info(cls, info, scopes=None): delegates = info.get("delegates") quota_project_id = info.get("quota_project_id") scopes = scopes or info.get("scopes") - trust_boundary = info.get("trust_boundary") + regional_access_boundary = info.get("regional_access_boundary") + if regional_access_boundary is None: + regional_access_boundary = info.get("trust_boundary") + if regional_access_boundary is not None: + warnings.warn( + "'trust_boundary' is deprecated and will be removed in a future version. Please use 'regional_access_boundary'.", + DeprecationWarning, + stacklevel=2, + ) - return cls( + initial_creds = cls( source_credentials, target_principal, scopes, delegates, quota_project_id=quota_project_id, - trust_boundary=trust_boundary, ) + if regional_access_boundary: + initial_creds = initial_creds.with_regional_access_boundary( + regional_access_boundary + ) + + return initial_creds + class IDTokenCredentials(credentials.CredentialsWithQuotaProject): """Open ID Connect ID Token-based service account credentials.""" diff --git a/google/auth/transport/requests.py b/google/auth/transport/requests.py index d1ff8f368..1156e3e25 100644 --- a/google/auth/transport/requests.py +++ b/google/auth/transport/requests.py @@ -472,6 +472,18 @@ def configure_mtls_channel(self, client_cert_callback=None): new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc + def _is_stale_regional_access_boundary_error(self, response): + """Checks if the response indicates a stale regional access boundary.""" + if response.status_code != 400: + return False + + try: + # The response data is bytes, decode it to a string. + response_text = response.content.decode("utf-8") + return "stale regional access boundary" in response_text.lower() + except (UnicodeDecodeError, AttributeError): + return False + def request( self, method, @@ -511,6 +523,7 @@ def request( # Use a kwarg for this instead of an attribute to maintain # thread-safety. _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0) + _stale_boundary_retried = kwargs.pop("_stale_boundary_retried", False) # Make a copy of the headers. They will be modified by the credentials # and we want to pass the original headers if we recurse. @@ -584,6 +597,26 @@ def request( **kwargs ) + # If the response indicated a stale regional access boundary, clear the + # cached boundary and re-attempt the request. This is only done once. + if ( + self._is_stale_regional_access_boundary_error(response) + and not _stale_boundary_retried + ): + _LOGGER.info("Stale regional access boundary detected, clearing and retrying.") + self.credentials.handle_stale_regional_access_boundary(auth_request) + # Recurse, passing in the original headers and marking that we have retried. + return self.request( + method, + url, + data=data, + headers=headers, + max_allowed_time=remaining_time, + timeout=timeout, + _stale_boundary_retried=True, + **kwargs + ) + return response @property diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py index 9c0e63098..50ebb09e8 100644 --- a/google/oauth2/_client.py +++ b/google/oauth2/_client.py @@ -510,15 +510,15 @@ def refresh_grant( return _handle_refresh_grant_response(response_data, refresh_token) -def _lookup_trust_boundary(request, url, headers=None): - """Implements the global lookup of a credential trust boundary. +def _lookup_regional_access_boundary(request, url, headers=None): + """Implements the global lookup of a credential Regional Access Boundary. For the lookup, we send a request to the global lookup endpoint and then parse the response. Service account credentials, workload identity - pools and workforce pools implementation may have trust boundaries configured. + pools and workforce pools implementation may have Regional Access Boundaries configured. Args: request (google.auth.transport.Request): A callable used to make HTTP requests. - url (str): The trust boundary lookup url. + url (str): The Regional Access Boundary lookup url. headers (Optional[Mapping[str, str]]): The headers for the request. Returns: Mapping[str,list|str]: A dictionary containing @@ -531,33 +531,30 @@ def _lookup_trust_boundary(request, url, headers=None): ], "encodedLocations": "0xA30" } - If the credential is not set up with explicit trust boundaries, a trust boundary - of "all" will be returned as a default response. - { - "locations": [], - "encodedLocations": "0x0" - } Raises: exceptions.RefreshError: If the response status code is not 200. exceptions.MalformedError: If the response is not in a valid format. """ - response_data = _lookup_trust_boundary_request(request, url, headers=headers) - # In case of no-op response, the "locations" list may or may not be present as an empty list. + response_data = _lookup_regional_access_boundary_request( + request, url, headers=headers + ) if "encodedLocations" not in response_data: raise exceptions.MalformedError( - "Invalid trust boundary info: {}".format(response_data) + "Invalid Regional Access Boundary info: {}".format(response_data) ) return response_data -def _lookup_trust_boundary_request(request, url, can_retry=True, headers=None): - """Makes a request to the trust boundary lookup endpoint. +def _lookup_regional_access_boundary_request( + request, url, can_retry=True, headers=None +): + """Makes a request to the Regional Access Boundary lookup endpoint. Args: request (google.auth.transport.Request): A callable used to make HTTP requests. - url (str): The trust boundary lookup url. + url (str): The Regional Access Boundary lookup url. can_retry (bool): Enable or disable request retry behavior. Defaults to true. headers (Optional[Mapping[str, str]]): The headers for the request. @@ -568,7 +565,7 @@ def _lookup_trust_boundary_request(request, url, can_retry=True, headers=None): google.auth.exceptions.RefreshError: If the token endpoint returned an error. """ - response_status_ok, response_data, retryable_error = _lookup_trust_boundary_request_no_throw( + response_status_ok, response_data, retryable_error = _lookup_regional_access_boundary_request_no_throw( request, url, can_retry, headers ) if not response_status_ok: @@ -576,14 +573,16 @@ def _lookup_trust_boundary_request(request, url, can_retry=True, headers=None): return response_data -def _lookup_trust_boundary_request_no_throw(request, url, can_retry=True, headers=None): - """Makes a request to the trust boundary lookup endpoint. This +def _lookup_regional_access_boundary_request_no_throw( + request, url, can_retry=True, headers=None +): + """Makes a request to the Regional Access Boundary lookup endpoint. This function doesn't throw on response errors. Args: request (google.auth.transport.Request): A callable used to make HTTP requests. - url (str): The trust boundary lookup url. + url (str): The Regional Access Boundary lookup url. can_retry (bool): Enable or disable request retry behavior. Defaults to true. headers (Optional[Mapping[str, str]]): The headers for the request. diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 7520fe3bb..dabf6b98e 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -72,6 +72,7 @@ import copy import datetime +import warnings from google.auth import _constants from google.auth import _helpers @@ -92,7 +93,7 @@ class Credentials( credentials.Scoped, credentials.CredentialsWithQuotaProject, credentials.CredentialsWithTokenUri, - credentials.CredentialsWithTrustBoundary, + credentials.CredentialsWithRegionalAccessBoundary, ): """Service account credentials @@ -142,7 +143,6 @@ def __init__( additional_claims=None, always_use_jwt_access=False, universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ): """ Args: @@ -166,7 +166,6 @@ def __init__( universe_domain (str): The universe domain. The default universe domain is googleapis.com. For default value self signed jwt is used for token refresh. - trust_boundary (Mapping[str,str]): A credential trust boundary. .. note:: Typically one of the helper constructors :meth:`from_service_account_file` or @@ -196,7 +195,6 @@ def __init__( self._additional_claims = additional_claims else: self._additional_claims = {} - self._trust_boundary = trust_boundary @classmethod def _from_signer_and_info(cls, signer, info, **kwargs): @@ -214,7 +212,16 @@ def _from_signer_and_info(cls, signer, info, **kwargs): Raises: ValueError: If the info is not in the expected format. """ - return cls( + regional_access_boundary = info.get("regional_access_boundary") + if regional_access_boundary is None: + regional_access_boundary = info.get("trust_boundary") + if regional_access_boundary is not None: + warnings.warn( + "'trust_boundary' is deprecated and will be removed in a future version. Please use 'regional_access_boundary'.", + DeprecationWarning, + stacklevel=2, + ) + initial_creds = cls( signer, service_account_email=info["client_email"], token_uri=info["token_uri"], @@ -222,9 +229,13 @@ def _from_signer_and_info(cls, signer, info, **kwargs): universe_domain=info.get( "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN ), - trust_boundary=info.get("trust_boundary"), **kwargs, ) + if regional_access_boundary: + initial_creds = initial_creds.with_regional_access_boundary( + regional_access_boundary + ) + return initial_creds @classmethod def from_service_account_info(cls, info, **kwargs): @@ -296,9 +307,9 @@ def _make_copy(self): additional_claims=self._additional_claims.copy(), always_use_jwt_access=self._always_use_jwt_access, universe_domain=self._universe_domain, - trust_boundary=self._trust_boundary, ) cred._cred_file_path = self._cred_file_path + self._copy_regional_access_boundary_state(cred) return cred @_helpers.copy_docstring(credentials.Scoped) @@ -384,12 +395,6 @@ def with_token_uri(self, token_uri): cred._token_uri = token_uri return cred - @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) - def with_trust_boundary(self, trust_boundary): - cred = self._make_copy() - cred._trust_boundary = trust_boundary - return cred - def _make_authorization_grant_assertion(self): """Create the OAuth 2.0 assertion. @@ -433,7 +438,7 @@ def _metric_header_for_usage(self): return metrics.CRED_TYPE_SA_JWT return metrics.CRED_TYPE_SA_ASSERTION - @_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary) + @_helpers.copy_docstring(credentials.CredentialsWithRegionalAccessBoundary) def _refresh_token(self, request): if self._always_use_jwt_access and not self._jwt_credentials: # If self signed jwt should be used but jwt credential is not @@ -500,8 +505,8 @@ def _create_self_signed_jwt(self, audience): self, audience ) - def _build_trust_boundary_lookup_url(self): - """Builds and returns the URL for the trust boundary lookup API. + def _build_regional_access_boundary_lookup_url(self): + """Builds and returns the URL for the Regional Access Boundary lookup API. This method constructs the specific URL for the IAM Credentials API's `allowedLocations` endpoint, using the credential's universe domain @@ -512,13 +517,13 @@ def _build_trust_boundary_lookup_url(self): string, as it's required to form the URL. Returns: - str: The URL for the trust boundary lookup endpoint. + str: The URL for the Regional Access Boundary lookup endpoint. """ if not self.service_account_email: raise ValueError( - "Service account email is required to build the trust boundary lookup URL." + "Service account email is required to build the Regional Access Boundary lookup URL." ) - return _constants._SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format( + return _constants._SERVICE_ACCOUNT_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( universe_domain=self._universe_domain, service_account_email=self._service_account_email, ) diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py index 1c7706993..d26586624 100644 --- a/tests/compute_engine/test_credentials.py +++ b/tests/compute_engine/test_credentials.py @@ -18,6 +18,7 @@ import mock import pytest # type: ignore import responses # type: ignore +import warnings from google.auth import _helpers from google.auth import environment_vars @@ -62,9 +63,8 @@ class TestCredentials(object): credentials = None credentials_with_all_fields = None - VALID_TRUST_BOUNDARY = {"encodedLocations": "valid-encoded-locations"} - NO_OP_TRUST_BOUNDARY = {"encodedLocations": ""} - EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations" + VALID_REGIONAL_ACCESS_BOUNDARY = {"encodedLocations": "valid-encoded-locations"} + EXPECTED_REGIONAL_ACCESS_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations" @pytest.fixture(autouse=True) def credentials_fixture(self): @@ -258,18 +258,27 @@ def test_with_universe_domain(self): assert creds.universe_domain == "universe_domain" assert creds._universe_domain_cached - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): creds = self.credentials_with_all_fields new_boundary = {"encodedLocations": "new_boundary"} - new_creds = creds.with_trust_boundary(new_boundary) + new_creds = creds.with_regional_access_boundary(new_boundary) assert new_creds is not creds - assert new_creds._trust_boundary == new_boundary + assert new_creds._regional_access_boundary == new_boundary assert new_creds._service_account_email == creds._service_account_email assert new_creds._quota_project_id == creds._quota_project_id assert new_creds._scopes == creds._scopes assert new_creds._default_scopes == creds._default_scopes + def test_with_trust_boundary_deprecation_warning(self): + creds = self.credentials_with_all_fields + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + creds.with_trust_boundary({"encodedLocations": "new_boundary"}) + assert len(w) == 1 + assert issubclass(w[-1].category, DeprecationWarning) + assert "with_trust_boundary" in str(w[-1].message) + def test_token_usage_metrics(self): self.credentials.token = "token" self.credentials.expiry = None @@ -309,10 +318,10 @@ def test_user_provided_universe_domain(self, get_universe_domain): # domain endpoint. get_universe_domain.assert_not_called() - @mock.patch("google.oauth2._client._lookup_trust_boundary", autospec=True) + @mock.patch("google.oauth2._client._lookup_regional_access_boundary", autospec=True) @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - def test_refresh_trust_boundary_lookup_skipped_if_env_var_not_true( - self, mock_metadata_get, mock_lookup_tb + def test_refresh_regional_access_boundary_lookup_skipped_if_env_var_not_true( + self, mock_metadata_get, mock_lookup_rab ): creds = self.credentials request = mock.Mock() @@ -325,17 +334,20 @@ def test_refresh_trust_boundary_lookup_skipped_if_env_var_not_true( ] with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "false" + }, ): creds.refresh(request) - mock_lookup_tb.assert_not_called() - assert creds._trust_boundary is None + mock_lookup_rab.assert_not_called() + assert creds._regional_access_boundary is None - @mock.patch("google.oauth2._client._lookup_trust_boundary", autospec=True) + @mock.patch("google.oauth2._client._lookup_regional_access_boundary", autospec=True) @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - def test_refresh_trust_boundary_lookup_skipped_if_env_var_missing( - self, mock_metadata_get, mock_lookup_tb + def test_refresh_regional_access_boundary_lookup_skipped_if_env_var_missing( + self, mock_metadata_get, mock_lookup_rab ): creds = self.credentials request = mock.Mock() @@ -350,234 +362,25 @@ def test_refresh_trust_boundary_lookup_skipped_if_env_var_missing( with mock.patch.dict(os.environ, clear=True): creds.refresh(request) - mock_lookup_tb.assert_not_called() - assert creds._trust_boundary is None - - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - def test_refresh_trust_boundary_lookup_success( - self, mock_metadata_get, mock_lookup_tb - ): - mock_lookup_tb.return_value = { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - creds = self.credentials - request = mock.Mock() - - # The first call to _metadata.get is for service account info, the second - # for the access token, and the third for the universe domain. - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - # from get_universe_domain - "", - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - # Verify _metadata.get was called three times. - assert mock_metadata_get.call_count == 3 - # Verify lookup_trust_boundary was called with correct URL and token - expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" - mock_lookup_tb.assert_called_once_with( - request, expected_url, headers={"authorization": "Bearer mock_token"} - ) - # Verify trust boundary was set - assert creds._trust_boundary == { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - - # Verify x-allowed-locations header is set by apply() - headers_applied = {} - creds.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "0xABC" - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_trust_boundary_lookup_fails_no_cache( - self, mock_lookup_tb, mock_metadata_get - ): - mock_lookup_tb.side_effect = exceptions.RefreshError("Lookup failed") - creds = self.credentials - request = mock.Mock() - - # Mock metadata calls for token, universe domain, and service account info - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - # from get_universe_domain - "", - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - with pytest.raises(exceptions.RefreshError, match="Lookup failed"): - creds.refresh(request) - - assert creds._trust_boundary is None - assert mock_metadata_get.call_count == 3 - mock_lookup_tb.assert_called_once() - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_trust_boundary_lookup_fails_with_cached_data( - self, mock_lookup_tb, mock_metadata_get - ): - # First refresh: Successfully fetch a valid trust boundary. - mock_lookup_tb.return_value = { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token_1", "expires_in": 3600}, - # from get_universe_domain - "", - ] - creds = self.credentials - request = mock.Mock() - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - assert creds._trust_boundary == { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - mock_lookup_tb.assert_called_once() - - # Second refresh: Mock lookup to fail, but expect cached data to be preserved. - mock_lookup_tb.reset_mock() - mock_lookup_tb.side_effect = exceptions.RefreshError("Lookup failed") - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - # This refresh should not raise an error because a cached value exists. - mock_metadata_get.reset_mock() - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token_2", "expires_in": 3600}, - # from get_universe_domain - "", - ] - creds.refresh(request) - - assert creds._trust_boundary == { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - mock_lookup_tb.assert_called_once() - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_fetches_no_op_trust_boundary( - self, mock_lookup_tb, mock_metadata_get - ): - mock_lookup_tb.return_value = {"locations": [], "encodedLocations": "0x0"} - creds = self.credentials - request = mock.Mock() - - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - # from get_universe_domain - "", - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - assert creds._trust_boundary == {"locations": [], "encodedLocations": "0x0"} - assert mock_metadata_get.call_count == 3 - expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" - mock_lookup_tb.assert_called_once_with( - request, expected_url, headers={"authorization": "Bearer mock_token"} - ) - # Verify that an empty header was added. - headers_applied = {} - creds.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( - self, mock_lookup_tb, mock_metadata_get - ): - creds = self.credentials - # Use pre-cache universe domain to avoid an extra metadata call. - creds._universe_domain_cached = True - creds._trust_boundary = {"locations": [], "encodedLocations": "0x0"} - request = mock.Mock() - - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - # Verify trust boundary remained NO_OP - assert creds._trust_boundary == {"locations": [], "encodedLocations": "0x0"} - # Lookup should be skipped - mock_lookup_tb.assert_not_called() - # Two metadata calls for token refresh should have happened. - assert mock_metadata_get.call_count == 2 - - # Verify that an empty header was added. - headers_applied = {} - creds.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" + mock_lookup_rab.assert_not_called() + assert creds._regional_access_boundary is None @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True ) - @mock.patch( - "google.auth.compute_engine._metadata.get_universe_domain", autospec=True - ) - def test_build_trust_boundary_lookup_url_default_email( - self, mock_get_universe_domain, mock_get_service_account_info + def test_build_regional_access_boundary_lookup_url_default_email( + self, mock_get_service_account_info ): - # Test with default service account email, which needs resolution - creds = self.credentials - creds._service_account_email = "default" mock_get_service_account_info.return_value = { "email": "resolved-email@example.com" } - mock_get_universe_domain.return_value = "googleapis.com" - - url = creds._build_trust_boundary_lookup_url() + creds = self.credentials + creds._universe_domain_cached = True + url = creds._build_regional_access_boundary_lookup_url() mock_get_service_account_info.assert_called_once_with(mock.ANY, "default") - mock_get_universe_domain.assert_called_once_with(mock.ANY) - assert url == ( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" - ) + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" + assert url == expected_url @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True @@ -585,7 +388,7 @@ def test_build_trust_boundary_lookup_url_default_email( @mock.patch( "google.auth.compute_engine._metadata.get_universe_domain", autospec=True ) - def test_build_trust_boundary_lookup_url_explicit_email( + def test_build_regional_access_boundary_lookup_url_explicit_email( self, mock_get_universe_domain, mock_get_service_account_info ): # Test with an explicit service account email, no resolution needed @@ -593,7 +396,7 @@ def test_build_trust_boundary_lookup_url_explicit_email( creds._service_account_email = FAKE_SERVICE_ACCOUNT_EMAIL mock_get_universe_domain.return_value = "googleapis.com" - url = creds._build_trust_boundary_lookup_url() + url = creds._build_regional_access_boundary_lookup_url() mock_get_service_account_info.assert_not_called() mock_get_universe_domain.assert_called_once_with(mock.ANY) @@ -607,13 +410,13 @@ def test_build_trust_boundary_lookup_url_explicit_email( @mock.patch( "google.auth.compute_engine._metadata.get_universe_domain", autospec=True ) - def test_build_trust_boundary_lookup_url_non_default_universe( + def test_build_regional_access_boundary_lookup_url_non_default_universe( self, mock_get_universe_domain, mock_get_service_account_info ): # Test with a non-default universe domain creds = self.credentials_with_all_fields - url = creds._build_trust_boundary_lookup_url() + url = creds._build_regional_access_boundary_lookup_url() # Universe domain is cached and email is explicit, so no metadata calls needed. mock_get_service_account_info.assert_not_called() @@ -625,26 +428,21 @@ def test_build_trust_boundary_lookup_url_non_default_universe( @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True ) - def test_build_trust_boundary_lookup_url_get_service_account_info_fails( + def test_build_regional_access_boundary_lookup_url_get_service_account_info_fails( self, mock_get_service_account_info ): - # Test scenario where get_service_account_info fails mock_get_service_account_info.side_effect = exceptions.TransportError( - "Failed to get info" + "Metadata server error" ) creds = self.credentials - creds._service_account_email = "default" - - with pytest.raises( - exceptions.RefreshError, - match=r"Failed to get service account email for trust boundary lookup: .*", - ): - creds._build_trust_boundary_lookup_url() + with pytest.raises(exceptions.RefreshError): + creds._build_regional_access_boundary_lookup_url() + mock_get_service_account_info.assert_called_once() @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True ) - def test_build_trust_boundary_lookup_url_no_email( + def test_build_regional_access_boundary_lookup_url_no_email( self, mock_get_service_account_info ): # Test with default service account email, which needs resolution, but metadata @@ -654,7 +452,7 @@ def test_build_trust_boundary_lookup_url_no_email( mock_get_service_account_info.return_value = {"scopes": ["one", "two"]} with pytest.raises(exceptions.RefreshError) as excinfo: - creds._build_trust_boundary_lookup_url() + creds._build_regional_access_boundary_lookup_url() assert excinfo.match(r"missing 'email' field") diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py index b17ba542d..d8ad623c7 100644 --- a/tests/oauth2/test__client.py +++ b/tests/oauth2/test__client.py @@ -632,7 +632,7 @@ def test__token_endpoint_request_no_throw_with_retry(can_retry): assert mock_request.call_count == 1 -def test_lookup_trust_boundary(): +def test_lookup_regional_access_boundary(): response_data = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0x80080000000000", @@ -647,7 +647,9 @@ def test_lookup_trust_boundary(): url = "http://example.com" headers = {"Authorization": "Bearer access_token"} - response = _client._lookup_trust_boundary(mock_request, url, headers=headers) + response = _client._lookup_regional_access_boundary( + mock_request, url, headers=headers + ) assert response["encodedLocations"] == "0x80080000000000" assert response["locations"] == ["us-central1", "us-east1"] @@ -655,47 +657,7 @@ def test_lookup_trust_boundary(): mock_request.assert_called_once_with(method="GET", url=url, headers=headers) -def test_lookup_trust_boundary_no_op_response_without_locations(): - response_data = {"encodedLocations": "0x0"} - - mock_response = mock.create_autospec(transport.Response, instance=True) - mock_response.status = http_client.OK - mock_response.data = json.dumps(response_data).encode("utf-8") - - mock_request = mock.create_autospec(transport.Request) - mock_request.return_value = mock_response - - url = "http://example.com" - headers = {"Authorization": "Bearer access_token"} - # for the response to be valid, we only need encodedLocations to be present. - response = _client._lookup_trust_boundary(mock_request, url, headers=headers) - assert response["encodedLocations"] == "0x0" - assert "locations" not in response - - mock_request.assert_called_once_with(method="GET", url=url, headers=headers) - - -def test_lookup_trust_boundary_no_op_response(): - response_data = {"locations": [], "encodedLocations": "0x0"} - - mock_response = mock.create_autospec(transport.Response, instance=True) - mock_response.status = http_client.OK - mock_response.data = json.dumps(response_data).encode("utf-8") - - mock_request = mock.create_autospec(transport.Request) - mock_request.return_value = mock_response - - url = "http://example.com" - headers = {"Authorization": "Bearer access_token"} - response = _client._lookup_trust_boundary(mock_request, url, headers=headers) - - assert response["encodedLocations"] == "0x0" - assert response["locations"] == [] - - mock_request.assert_called_once_with(method="GET", url=url, headers=headers) - - -def test_lookup_trust_boundary_error(): +def test_lookup_regional_access_boundary_error(): mock_response = mock.create_autospec(transport.Response, instance=True) mock_response.status = http_client.INTERNAL_SERVER_ERROR mock_response.data = "this is an error message" @@ -706,32 +668,13 @@ def test_lookup_trust_boundary_error(): url = "http://example.com" headers = {"Authorization": "Bearer access_token"} with pytest.raises(exceptions.RefreshError) as excinfo: - _client._lookup_trust_boundary(mock_request, url, headers=headers) + _client._lookup_regional_access_boundary(mock_request, url, headers=headers) assert excinfo.match("this is an error message") mock_request.assert_called_with(method="GET", url=url, headers=headers) -def test_lookup_trust_boundary_missing_encoded_locations(): - response_data = {"locations": [], "bad_field": "0x0"} - - mock_response = mock.create_autospec(transport.Response, instance=True) - mock_response.status = http_client.OK - mock_response.data = json.dumps(response_data).encode("utf-8") - - mock_request = mock.create_autospec(transport.Request) - mock_request.return_value = mock_response - - url = "http://example.com" - headers = {"Authorization": "Bearer access_token"} - with pytest.raises(exceptions.MalformedError) as excinfo: - _client._lookup_trust_boundary(mock_request, url, headers=headers) - assert excinfo.match("Invalid trust boundary info") - - mock_request.assert_called_once_with(method="GET", url=url, headers=headers) - - -def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): +def test_lookup_regional_access_boundary_internal_failure_and_retry_failure_error(): retryable_error = mock.create_autospec(transport.Response, instance=True) retryable_error.status = http_client.BAD_REQUEST retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( @@ -750,7 +693,7 @@ def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): headers = {"Authorization": "Bearer access_token"} with pytest.raises(exceptions.RefreshError): - _client._lookup_trust_boundary_request( + _client._lookup_regional_access_boundary_request( request, "http://example.com", headers=headers ) # request should be called three times. Two retryable errors and one @@ -760,14 +703,17 @@ def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): assert call[1]["headers"] == headers -def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): +def test_lookup_regional_access_boundary_internal_failure_and_retry_succeeds(): retryable_error = mock.create_autospec(transport.Response, instance=True) retryable_error.status = http_client.BAD_REQUEST retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( "utf-8" ) - response_data = {"locations": [], "encodedLocations": "0x0"} + response_data = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0xVALIDHEX", + } response = mock.create_autospec(transport.Response, instance=True) response.status = http_client.OK response.data = json.dumps(response_data).encode("utf-8") @@ -777,7 +723,7 @@ def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): headers = {"Authorization": "Bearer access_token"} request.side_effect = [retryable_error, response] - _ = _client._lookup_trust_boundary_request( + _ = _client._lookup_regional_access_boundary_request( request, "http://example.com", headers=headers ) @@ -786,7 +732,7 @@ def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): assert call[1]["headers"] == headers -def test_lookup_trust_boundary_with_headers(): +def test_lookup_regional_access_boundary_with_headers(): response_data = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0x80080000000000", @@ -800,7 +746,9 @@ def test_lookup_trust_boundary_with_headers(): mock_request.return_value = mock_response headers = {"Authorization": "Bearer access_token", "x-test-header": "test-value"} - _client._lookup_trust_boundary(mock_request, "http://example.com", headers=headers) + _client._lookup_regional_access_boundary( + mock_request, "http://example.com", headers=headers + ) mock_request.assert_called_once_with( method="GET", url="http://example.com", headers=headers diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index d23746fdf..e8b77163a 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -15,6 +15,7 @@ import datetime import json import os +import warnings import mock import pytest # type: ignore @@ -60,15 +61,11 @@ class TestCredentials(object): SERVICE_ACCOUNT_EMAIL = "service-account@example.com" TOKEN_URI = "https://example.com/oauth2/token" - NO_OP_TRUST_BOUNDARY = { - "locations": credentials.NO_OP_TRUST_BOUNDARY_LOCATIONS, - "encodedLocations": credentials.NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS, - } - VALID_TRUST_BOUNDARY = { + VALID_REGIONAL_ACCESS_BOUNDARY = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEXSA", } - EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = ( + EXPECTED_REGIONAL_ACCESS_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = ( "https://iamcredentials.googleapis.com/v1/projects/-" "/serviceAccounts/service-account@example.com/allowedLocations" ) @@ -77,15 +74,17 @@ class TestCredentials(object): def make_credentials( cls, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, # Align with Credentials class default + regional_access_boundary=None, # Align with Credentials class default ): - return service_account.Credentials( + creds = service_account.Credentials( SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI, universe_domain=universe_domain, - trust_boundary=trust_boundary, ) + if regional_access_boundary: + creds = creds.with_regional_access_boundary(regional_access_boundary) + return creds def test_get_cred_info(self): credentials = self.make_credentials() @@ -270,18 +269,27 @@ def test__with_always_use_jwt_access_non_default_universe_domain(self): "always_use_jwt_access should be True for non-default universe domain" ) - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): credentials = self.make_credentials() new_boundary = {"encodedLocations": "new_boundary"} - new_credentials = credentials.with_trust_boundary(new_boundary) + new_credentials = credentials.with_regional_access_boundary(new_boundary) assert new_credentials is not credentials - assert new_credentials._trust_boundary == new_boundary + assert new_credentials._regional_access_boundary == new_boundary assert new_credentials._signer == credentials._signer assert ( new_credentials.service_account_email == credentials.service_account_email ) + def test_with_trust_boundary_deprecation_warning(self): + credentials = self.make_credentials() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + credentials.with_trust_boundary({"encodedLocations": "new_boundary"}) + assert len(w) == 1 + assert issubclass(w[-1].category, DeprecationWarning) + assert "with_trust_boundary" in str(w[-1].message) + def test__make_authorization_grant_assertion(self): credentials = self.make_credentials() token = credentials._make_authorization_grant_assertion() @@ -529,14 +537,14 @@ def test_refresh_success(self, jwt_grant): # Check that the credentials are valid (have a token and are not expired). assert credentials.valid - # Trust boundary should be None since env var is not set and no initial + # Regional Access Boundary should be None since env var is not set and no initial # boundary was provided. - assert credentials._trust_boundary is None + assert credentials._regional_access_boundary is None - @mock.patch("google.oauth2._client._lookup_trust_boundary") + @mock.patch("google.oauth2._client._lookup_regional_access_boundary") @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_skips_trust_boundary_lookup_non_default_universe( - self, mock_jwt_grant, mock_lookup_trust_boundary + def test_refresh_skips_regional_access_boundary_lookup_non_default_universe( + self, mock_jwt_grant, mock_lookup_rab ): # Create credentials with a non-default universe domain credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) @@ -549,14 +557,17 @@ def test_refresh_skips_trust_boundary_lookup_non_default_universe( request = mock.create_autospec(transport.Request, instance=True) with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): credentials.refresh(request) # Ensure jwt_grant was called (token refresh happened) mock_jwt_grant.assert_called_once() - # Ensure trust boundary lookup was not called - mock_lookup_trust_boundary.assert_not_called() + # Ensure Regional Access Boundary lookup was not called + mock_lookup_rab.assert_not_called() # Verify that x-allowed-locations header is not set by apply() headers_applied = {} credentials.apply(headers_applied) @@ -661,6 +672,15 @@ def test_refresh_missing_jwt_credentials(self): # jwt credentials should have been automatically created with scopes assert credentials._jwt_credentials is not None + def test_build_regional_access_boundary_lookup_url(self): + credentials = self.make_credentials() + expected_url = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + "/serviceAccounts/{}/allowedLocations".format(self.SERVICE_ACCOUNT_EMAIL) + ) + url = credentials._build_regional_access_boundary_lookup_url() + assert url == expected_url + def test_refresh_non_gdu_domain_wide_delegation_not_supported(self): credentials = self.make_credentials(universe_domain="foo") credentials._subject = "bar@example.com" @@ -670,205 +690,12 @@ def test_refresh_non_gdu_domain_wide_delegation_not_supported(self): credentials.refresh(None) assert excinfo.match("domain wide delegation is not supported") - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_success_with_valid_trust_boundary( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Start with no boundary. - credentials = self.make_credentials(trust_boundary=None) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - # Mock the trust boundary lookup to return a valid boundary. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - - # Verify trust boundary was set. - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - - # Verify the mock was called with the correct URL. - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - - # Verify x-allowed-locations header is set correctly by apply(). - headers_applied = {} - credentials.apply(headers_applied) - assert ( - headers_applied["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_fetches_no_op_trust_boundary( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Start with no trust boundary - credentials = self.make_credentials(trust_boundary=None) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - mock_lookup_trust_boundary.return_value = self.NO_OP_TRUST_BOUNDARY - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - credentials = self.make_credentials( - trust_boundary=self.NO_OP_TRUST_BOUNDARY - ) # Start with NO_OP - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - # Verify trust boundary remained NO_OP - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - - # Lookup should be skipped - mock_lookup_trust_boundary.assert_not_called() - - # Verify that an empty header was added. - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_trust_boundary_lookup_fails_no_cache( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Start with no trust boundary - credentials = self.make_credentials(trust_boundary=None) - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - mock_jwt_grant.return_value = ( - "mock_access_token", - _helpers.utcnow() + datetime.timedelta(seconds=3600), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - # Mock the trust boundary lookup to raise an error - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), pytest.raises(exceptions.RefreshError, match="Lookup failed"): - credentials.refresh(request) - - assert credentials._trust_boundary is None - mock_lookup_trust_boundary.assert_called_once() - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_trust_boundary_lookup_fails_with_cached_data( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Initial setup: Credentials with no trust boundary. - credentials = self.make_credentials(trust_boundary=None) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - # First refresh: Successfully fetch a valid trust boundary. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - - # Second refresh: Mock lookup to fail, but expect cached data to be preserved. - mock_lookup_trust_boundary.reset_mock() - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) # This should NOT raise an exception - - assert credentials.valid # Credentials should still be valid - assert ( - credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - ) # Cached data should be preserved - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={ - "authorization": "Bearer token", - "x-allowed-locations": self.VALID_TRUST_BOUNDARY["encodedLocations"], - }, - ) # Lookup should have been attempted again - - def test_build_trust_boundary_lookup_url_no_email(self): + def test_build_regional_access_boundary_lookup_url_no_email(self): credentials = self.make_credentials() credentials._service_account_email = None with pytest.raises(ValueError) as excinfo: - credentials._build_trust_boundary_lookup_url() + credentials._build_regional_access_boundary_lookup_url() assert "Service account email is required" in str(excinfo.value) diff --git a/tests/test_aws.py b/tests/test_aws.py index 4f70bda4f..7585cf6dc 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -75,6 +75,10 @@ # Each tuple contains the following entries: # region, time, credentials, original_request, signed_request +VALID_REGIONAL_ACCESS_BOUNDARY = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0xVALIDHEXSA", +} VALID_TOKEN_URLS = [ "https://sts.googleapis.com", "https://us-east-1.sts.googleapis.com", @@ -880,8 +884,9 @@ def make_credentials( scopes=None, default_scopes=None, service_account_impersonation_url=None, + regional_access_boundary=None, ): - return aws.Credentials( + creds = aws.Credentials( audience=AUDIENCE, subject_token_type=SUBJECT_TOKEN_TYPE, token_url=token_url, @@ -895,6 +900,9 @@ def make_credentials( scopes=scopes, default_scopes=default_scopes, ) + if regional_access_boundary: + creds = creds.with_regional_access_boundary(regional_access_boundary) + return creds @classmethod def assert_aws_metadata_request_kwargs( @@ -971,7 +979,6 @@ def test_from_info_full_options(self, mock_init): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1001,7 +1008,6 @@ def test_from_info_required_options_only(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1033,9 +1039,28 @@ def test_from_info_supplier(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) + def test_from_info_with_regional_access_boundary(self): + regional_access_boundary = VALID_REGIONAL_ACCESS_BOUNDARY + credentials = aws.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + "regional_access_boundary": regional_access_boundary, + } + ) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + assert credentials._regional_access_boundary == regional_access_boundary + assert credentials._regional_access_boundary_expiry is not None + assert ( + credentials._regional_access_boundary_expiry - _helpers.utcnow() + ).total_seconds() > 0 + @mock.patch.object(aws.Credentials, "__init__", return_value=None) def test_from_file_full_options(self, mock_init, tmpdir): info = { @@ -1071,7 +1096,6 @@ def test_from_file_full_options(self, mock_init, tmpdir): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1102,9 +1126,29 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) + def test_from_file_with_regional_access_boundary(self, tmpdir): + regional_access_boundary = VALID_REGIONAL_ACCESS_BOUNDARY + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + "regional_access_boundary": regional_access_boundary, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = aws.Credentials.from_file(str(config_file)) + + # Confirm aws.Credentials instance initialized with the expected parameters. + assert isinstance(credentials, aws.Credentials) + assert credentials._regional_access_boundary == regional_access_boundary + assert credentials._regional_access_boundary_expiry is not None + assert ( + credentials._regional_access_boundary_expiry - _helpers.utcnow() + ).total_seconds() > 0 + def test_constructor_invalid_credential_source(self): # Provide invalid credential source. credential_source = {"unsupported": "value"} @@ -1900,321 +1944,6 @@ def test_retrieve_subject_token_error_determining_aws_security_creds(self): assert excinfo.match(r"Unable to retrieve AWS security credentials") - @mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ) - @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_without_impersonation_ignore_default_scopes( - self, utcnow, mock_auth_lib_value - ): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - expected_subject_token = self.make_serialized_aws_signed_request( - aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) - ) - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + BASIC_AUTH_ENCODING, - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false source/aws", - } - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": " ".join(SCOPES), - "subject_token": expected_subject_token, - "subject_token_type": SUBJECT_TOKEN_TYPE, - } - request = self.make_mock_request( - region_status=http_client.OK, - region_name=self.AWS_REGION, - role_status=http_client.OK, - role_name=self.AWS_ROLE, - security_credentials_status=http_client.OK, - security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, - token_status=http_client.OK, - token_data=self.SUCCESS_RESPONSE, - ) - credentials = self.make_credentials( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - credential_source=self.CREDENTIAL_SOURCE, - quota_project_id=QUOTA_PROJECT_ID, - scopes=SCOPES, - # Default scopes should be ignored. - default_scopes=["ignored"], - ) - - credentials.refresh(request) - - assert len(request.call_args_list) == 4 - # Fourth request should be sent to GCP STS endpoint. - self.assert_token_request_kwargs( - request.call_args_list[3][1], token_headers, token_request_data - ) - assert credentials.token == self.SUCCESS_RESPONSE["access_token"] - assert credentials.quota_project_id == QUOTA_PROJECT_ID - assert credentials.scopes == SCOPES - assert credentials.default_scopes == ["ignored"] - - @mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ) - @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_without_impersonation_use_default_scopes( - self, utcnow, mock_auth_lib_value - ): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - expected_subject_token = self.make_serialized_aws_signed_request( - aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) - ) - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + BASIC_AUTH_ENCODING, - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false source/aws", - } - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": " ".join(SCOPES), - "subject_token": expected_subject_token, - "subject_token_type": SUBJECT_TOKEN_TYPE, - } - request = self.make_mock_request( - region_status=http_client.OK, - region_name=self.AWS_REGION, - role_status=http_client.OK, - role_name=self.AWS_ROLE, - security_credentials_status=http_client.OK, - security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, - token_status=http_client.OK, - token_data=self.SUCCESS_RESPONSE, - ) - credentials = self.make_credentials( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - credential_source=self.CREDENTIAL_SOURCE, - quota_project_id=QUOTA_PROJECT_ID, - scopes=None, - # Default scopes should be used since user specified scopes are none. - default_scopes=SCOPES, - ) - - credentials.refresh(request) - - assert len(request.call_args_list) == 4 - # Fourth request should be sent to GCP STS endpoint. - self.assert_token_request_kwargs( - request.call_args_list[3][1], token_headers, token_request_data - ) - assert credentials.token == self.SUCCESS_RESPONSE["access_token"] - assert credentials.quota_project_id == QUOTA_PROJECT_ID - assert credentials.scopes is None - assert credentials.default_scopes == SCOPES - - @mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ) - @mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ) - @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_with_impersonation_ignore_default_scopes( - self, utcnow, mock_metrics_header_value, mock_auth_lib_value - ): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) - ).isoformat("T") + "Z" - expected_subject_token = self.make_serialized_aws_signed_request( - aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) - ) - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + BASIC_AUTH_ENCODING, - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false source/aws", - } - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": "https://www.googleapis.com/auth/iam", - "subject_token": expected_subject_token, - "subject_token_type": SUBJECT_TOKEN_TYPE, - } - # Service account impersonation request/response. - impersonation_response = { - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": expire_time, - } - impersonation_headers = { - "Content-Type": "application/json", - "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), - "x-goog-user-project": QUOTA_PROJECT_ID, - "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - # TODO(negarb): Uncomment and update when trust boundary is supported - # for external account credentials. - # "x-allowed-locations": "0x0", - } - impersonation_request_data = { - "delegates": None, - "scope": SCOPES, - "lifetime": "3600s", - } - request = self.make_mock_request( - region_status=http_client.OK, - region_name=self.AWS_REGION, - role_status=http_client.OK, - role_name=self.AWS_ROLE, - security_credentials_status=http_client.OK, - security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, - token_status=http_client.OK, - token_data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data=impersonation_response, - ) - credentials = self.make_credentials( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - credential_source=self.CREDENTIAL_SOURCE, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - quota_project_id=QUOTA_PROJECT_ID, - scopes=SCOPES, - # Default scopes should be ignored. - default_scopes=["ignored"], - ) - - credentials.refresh(request) - - assert len(request.call_args_list) == 5 - # Fourth request should be sent to GCP STS endpoint. - self.assert_token_request_kwargs( - request.call_args_list[3][1], token_headers, token_request_data - ) - # Fifth request should be sent to iamcredentials endpoint for service - # account impersonation. - self.assert_impersonation_request_kwargs( - request.call_args_list[4][1], - impersonation_headers, - impersonation_request_data, - ) - assert credentials.token == impersonation_response["accessToken"] - assert credentials.quota_project_id == QUOTA_PROJECT_ID - assert credentials.scopes == SCOPES - assert credentials.default_scopes == ["ignored"] - - @mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ) - @mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ) - @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_with_impersonation_use_default_scopes( - self, utcnow, mock_metrics_header_value, mock_auth_lib_value - ): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) - ).isoformat("T") + "Z" - expected_subject_token = self.make_serialized_aws_signed_request( - aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) - ) - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + BASIC_AUTH_ENCODING, - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false source/aws", - } - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": "https://www.googleapis.com/auth/iam", - "subject_token": expected_subject_token, - "subject_token_type": SUBJECT_TOKEN_TYPE, - } - # Service account impersonation request/response. - impersonation_response = { - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": expire_time, - } - impersonation_headers = { - "Content-Type": "application/json", - "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), - "x-goog-user-project": QUOTA_PROJECT_ID, - "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - # "x-allowed-locations": "0x0", - } - impersonation_request_data = { - "delegates": None, - "scope": SCOPES, - "lifetime": "3600s", - } - request = self.make_mock_request( - region_status=http_client.OK, - region_name=self.AWS_REGION, - role_status=http_client.OK, - role_name=self.AWS_ROLE, - security_credentials_status=http_client.OK, - security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, - token_status=http_client.OK, - token_data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data=impersonation_response, - ) - credentials = self.make_credentials( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - credential_source=self.CREDENTIAL_SOURCE, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - quota_project_id=QUOTA_PROJECT_ID, - scopes=None, - # Default scopes should be used since user specified scopes are none. - default_scopes=SCOPES, - ) - - credentials.refresh(request) - - assert len(request.call_args_list) == 5 - # Fourth request should be sent to GCP STS endpoint. - self.assert_token_request_kwargs( - request.call_args_list[3][1], token_headers, token_request_data - ) - # Fifth request should be sent to iamcredentials endpoint for service - # account impersonation. - self.assert_impersonation_request_kwargs( - request.call_args_list[4][1], - impersonation_headers, - impersonation_request_data, - ) - assert credentials.token == impersonation_response["accessToken"] - assert credentials.quota_project_id == QUOTA_PROJECT_ID - assert credentials.scopes is None - assert credentials.default_scopes == SCOPES - - def test_refresh_with_retrieve_subject_token_error(self): - request = self.make_mock_request(region_status=http_client.BAD_REQUEST) - credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - credentials.refresh(request) - - assert excinfo.match(r"Unable to retrieve AWS region") - @mock.patch("google.auth._helpers.utcnow") def test_retrieve_subject_token_success_with_supplier(self, utcnow): utcnow.return_value = datetime.datetime.strptime( @@ -2256,207 +1985,3 @@ def test_retrieve_subject_token_success_with_supplier_session_token(self, utcnow assert subject_token == self.make_serialized_aws_signed_request( aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) ) - - @mock.patch("google.auth._helpers.utcnow") - def test_retrieve_subject_token_success_with_supplier_correct_context(self, utcnow): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - request = self.make_mock_request() - expected_context = external_account.SupplierContext( - SUBJECT_TOKEN_TYPE, AUDIENCE - ) - - security_credentials = aws.AwsSecurityCredentials( - ACCESS_KEY_ID, SECRET_ACCESS_KEY - ) - supplier = TestAwsSecurityCredentialsSupplier( - security_credentials=security_credentials, - region=self.AWS_REGION, - expected_context=expected_context, - ) - - credentials = self.make_credentials(aws_security_credentials_supplier=supplier) - - credentials.retrieve_subject_token(request) - - def test_retrieve_subject_token_error_with_supplier(self): - request = self.make_mock_request() - expected_exception = exceptions.RefreshError("Test error") - supplier = TestAwsSecurityCredentialsSupplier( - region=self.AWS_REGION, credentials_exception=expected_exception - ) - - credentials = self.make_credentials(aws_security_credentials_supplier=supplier) - - with pytest.raises(exceptions.RefreshError) as excinfo: - credentials.refresh(request) - - assert excinfo.match(r"Test error") - - def test_retrieve_subject_token_error_with_supplier_region(self): - request = self.make_mock_request() - expected_exception = exceptions.RefreshError("Test error") - security_credentials = aws.AwsSecurityCredentials( - ACCESS_KEY_ID, SECRET_ACCESS_KEY - ) - supplier = TestAwsSecurityCredentialsSupplier( - security_credentials=security_credentials, - region_exception=expected_exception, - ) - - credentials = self.make_credentials(aws_security_credentials_supplier=supplier) - - with pytest.raises(exceptions.RefreshError) as excinfo: - credentials.refresh(request) - - assert excinfo.match(r"Test error") - - @mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ) - @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_with_supplier_with_impersonation( - self, utcnow, mock_auth_lib_value - ): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) - ).isoformat("T") + "Z" - expected_subject_token = self.make_serialized_aws_signed_request( - aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) - ) - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + BASIC_AUTH_ENCODING, - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false source/programmatic", - } - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": "https://www.googleapis.com/auth/iam", - "subject_token": expected_subject_token, - "subject_token_type": SUBJECT_TOKEN_TYPE, - } - # Service account impersonation request/response. - impersonation_response = { - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": expire_time, - } - impersonation_headers = { - "Content-Type": "application/json", - "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), - "x-goog-user-project": QUOTA_PROJECT_ID, - "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - # "x-allowed-locations": "0x0", - } - impersonation_request_data = { - "delegates": None, - "scope": SCOPES, - "lifetime": "3600s", - } - request = self.make_mock_request( - token_status=http_client.OK, - token_data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data=impersonation_response, - ) - - supplier = TestAwsSecurityCredentialsSupplier( - security_credentials=aws.AwsSecurityCredentials( - ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN - ), - region=self.AWS_REGION, - ) - - credentials = self.make_credentials( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - aws_security_credentials_supplier=supplier, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - quota_project_id=QUOTA_PROJECT_ID, - scopes=SCOPES, - # Default scopes should be ignored. - default_scopes=["ignored"], - ) - - credentials.refresh(request) - - assert len(request.call_args_list) == 2 - # First request should be sent to GCP STS endpoint. - self.assert_token_request_kwargs( - request.call_args_list[0][1], token_headers, token_request_data - ) - # Second request should be sent to iamcredentials endpoint for service - # account impersonation. - self.assert_impersonation_request_kwargs( - request.call_args_list[1][1], - impersonation_headers, - impersonation_request_data, - ) - assert credentials.token == impersonation_response["accessToken"] - assert credentials.quota_project_id == QUOTA_PROJECT_ID - assert credentials.scopes == SCOPES - assert credentials.default_scopes == ["ignored"] - - @mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ) - @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_with_supplier(self, utcnow, mock_auth_lib_value): - utcnow.return_value = datetime.datetime.strptime( - self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" - ) - expected_subject_token = self.make_serialized_aws_signed_request( - aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN) - ) - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + BASIC_AUTH_ENCODING, - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false source/programmatic", - } - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": " ".join(SCOPES), - "subject_token": expected_subject_token, - "subject_token_type": SUBJECT_TOKEN_TYPE, - } - request = self.make_mock_request( - token_status=http_client.OK, token_data=self.SUCCESS_RESPONSE - ) - - supplier = TestAwsSecurityCredentialsSupplier( - security_credentials=aws.AwsSecurityCredentials( - ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN - ), - region=self.AWS_REGION, - ) - - credentials = self.make_credentials( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - aws_security_credentials_supplier=supplier, - quota_project_id=QUOTA_PROJECT_ID, - scopes=SCOPES, - # Default scopes should be ignored. - default_scopes=["ignored"], - ) - - credentials.refresh(request) - - assert len(request.call_args_list) == 1 - # First request should be sent to GCP STS endpoint. - self.assert_token_request_kwargs( - request.call_args_list[0][1], token_headers, token_request_data - ) - assert credentials.token == self.SUCCESS_RESPONSE["access_token"] - assert credentials.quota_project_id == QUOTA_PROJECT_ID - assert credentials.scopes == SCOPES - assert credentials.default_scopes == ["ignored"] diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 1fb880096..3c1157e50 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -14,6 +14,7 @@ import datetime import os +import warnings import mock import pytest # type: ignore @@ -25,7 +26,7 @@ from google.oauth2 import _client -class CredentialsImpl(credentials.CredentialsWithTrustBoundary): +class CredentialsImpl(credentials.CredentialsWithRegionalAccessBoundary): def _refresh_token(self, request): self.token = "refreshed-token" self.expiry = ( @@ -37,10 +38,15 @@ def _refresh_token(self, request): def with_quota_project(self, quota_project_id): raise NotImplementedError() - def _build_trust_boundary_lookup_url(self): + def _build_regional_access_boundary_lookup_url(self): # Using self.token here to make the URL dynamic for testing purposes return "http://mock.url/lookup_for_{}".format(self.token) + def _make_copy(self): + new_credentials = self.__class__() + self._copy_regional_access_boundary_state(new_credentials) + return new_credentials + class CredentialsImplWithMetrics(credentials.Credentials): def refresh(self, request): @@ -118,10 +124,13 @@ def test_before_request(): assert "x-allowed-locations" not in headers -def test_before_request_with_trust_boundary(): +def test_before_request_with_regional_access_boundary(): DUMMY_BOUNDARY = "0xA30" credentials = CredentialsImpl() - credentials._trust_boundary = {"locations": [], "encodedLocations": DUMMY_BOUNDARY} + credentials._regional_access_boundary = { + "locations": [], + "encodedLocations": DUMMY_BOUNDARY, + } request = mock.Mock() headers = {} @@ -365,113 +374,190 @@ def test_token_state_no_expiry(): c.before_request(request, "http://example.com", "GET", {}) -class TestCredentialsWithTrustBoundary(object): - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_env_var_not_true(self, mock_lookup_tb): +class TestCredentialsWithRegionalAccessBoundary(object): + def test_with_regional_access_boundary_default_refresh_enabled(self): creds = CredentialsImpl() - request = mock.Mock() - - # Ensure env var is not "true" - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"} - ): - result = creds._refresh_trust_boundary(request) + boundary_info = {"encodedLocations": "0xABC"} + new_creds = creds.with_regional_access_boundary(boundary_info) - assert result is None - mock_lookup_tb.assert_not_called() + assert new_creds._regional_access_boundary == boundary_info + assert new_creds._regional_access_boundary_expiry is not None + assert ( + new_creds._regional_access_boundary_expiry - _helpers.utcnow() + ).total_seconds() > 0 + assert new_creds._regional_access_boundary_cooldown_expiry is None - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_env_var_missing(self, mock_lookup_tb): + def test_with_regional_access_boundary_proactive_refresh_disabled(self): creds = CredentialsImpl() - request = mock.Mock() + boundary_info = {"encodedLocations": "0xABC"} + new_creds = creds.with_regional_access_boundary( + boundary_info, enable_proactive_refresh=False + ) - # Ensure env var is missing - with mock.patch.dict(os.environ, clear=True): - result = creds._refresh_trust_boundary(request) + assert new_creds._regional_access_boundary == boundary_info + assert new_creds._regional_access_boundary_expiry is None - assert result is None - mock_lookup_tb.assert_not_called() + def test_with_regional_access_boundary_invalid_input(self): + creds = CredentialsImpl() + with pytest.raises(exceptions.InvalidValue): + creds.with_regional_access_boundary("not a dict") + with pytest.raises(exceptions.InvalidValue): + creds.with_regional_access_boundary({"wrong_key": "value"}) - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_non_default_universe(self, mock_lookup_tb): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_env_var_not_set( + self, mock_start_refresh + ): creds = CredentialsImpl() - creds._universe_domain = "my.universe.com" # Non-GDU - request = mock.Mock() + with mock.patch.dict(os.environ, clear=True): + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_already_set(self, mock_start_refresh): + creds = CredentialsImpl() + creds._regional_access_boundary = {"encodedLocations": "0xABC"} with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): - result = creds._refresh_trust_boundary(request) - - assert result is None - mock_lookup_tb.assert_not_called() + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_calls_client_and_build_url(self, mock_lookup_tb): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_not_expired(self, mock_start_refresh): creds = CredentialsImpl() - creds.token = "test_token" # For _build_trust_boundary_lookup_url - request = mock.Mock() - expected_url = "http://mock.url/lookup_for_test_token" - expected_boundary_info = {"encodedLocations": "0xABC"} - mock_lookup_tb.return_value = expected_boundary_info - - # Mock _build_trust_boundary_lookup_url to ensure it's called. - mock_build_url = mock.Mock(return_value=expected_url) - creds._build_trust_boundary_lookup_url = mock_build_url - + creds._regional_access_boundary_expiry = _helpers.utcnow() + datetime.timedelta( + minutes=5 + ) with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): - result = creds._lookup_trust_boundary(request) + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() - assert result == expected_boundary_info - mock_build_url.assert_called_once() - expected_headers = {"authorization": "Bearer test_token"} - mock_lookup_tb.assert_called_once_with( - request, expected_url, headers=expected_headers + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_cooldown_active( + self, mock_start_refresh + ): + creds = CredentialsImpl() + creds._regional_access_boundary_cooldown_expiry = _helpers.utcnow() + datetime.timedelta( + minutes=5 ) + with mock.patch.dict( + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, + ): + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_build_url_returns_none(self, mock_lookup_tb): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_for_regional_endpoint( + self, mock_start_refresh + ): creds = CredentialsImpl() - request = mock.Mock() - - # Mock _build_trust_boundary_lookup_url to return None - mock_build_url = mock.Mock(return_value=None) - creds._build_trust_boundary_lookup_url = mock_build_url - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): - with pytest.raises( - exceptions.InvalidValue, - match="Failed to build trust boundary lookup URL.", - ): - creds._lookup_trust_boundary(request) - - mock_build_url.assert_called_once() # Ensure _build_trust_boundary_lookup_url was called - mock_lookup_tb.assert_not_called() # Ensure _client.lookup_trust_boundary was not called + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "https://my-service.us-east1.rep.googleapis.com" + ) + mock_start_refresh.assert_not_called() - @mock.patch("google.auth.credentials._LOGGER") - @mock.patch("google.auth._helpers.is_logging_enabled", return_value=True) - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_refresh_trust_boundary_fails_with_cached_data_and_logging( - self, mock_lookup_tb, mock_is_logging_enabled, mock_logger - ): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_triggered(self, mock_start_refresh): creds = CredentialsImpl() - creds._trust_boundary = {"encodedLocations": "0xABC"} request = mock.Mock() - - refresh_error = exceptions.RefreshError("Lookup failed") - mock_lookup_tb.side_effect = refresh_error - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): - creds.refresh(request) + creds._maybe_start_regional_access_boundary_refresh( + request, "http://example.com" + ) + mock_start_refresh.assert_called_once_with(creds, request) - mock_lookup_tb.assert_called_once() - mock_is_logging_enabled.assert_called_once_with(mock_logger) - mock_logger.debug.assert_called_once_with( - "Using cached trust boundary due to refresh error: %s", refresh_error + def test_get_regional_access_boundary_header(self): + creds = CredentialsImpl() + creds._regional_access_boundary = {"encodedLocations": "0xABC"} + headers = creds._get_regional_access_boundary_header() + assert headers == {"x-allowed-locations": "0xABC"} + + creds._regional_access_boundary = None + headers = creds._get_regional_access_boundary_header() + assert headers == {} + + def test_copy_regional_access_boundary_state(self): + source_creds = CredentialsImpl() + source_creds._regional_access_boundary = {"encodedLocations": "0xABC"} + source_creds._regional_access_boundary_expiry = _helpers.utcnow() + source_creds._regional_access_boundary_cooldown_expiry = _helpers.utcnow() + + target_creds = CredentialsImpl() + source_creds._copy_regional_access_boundary_state(target_creds) + + assert ( + target_creds._regional_access_boundary + == source_creds._regional_access_boundary + ) + assert ( + target_creds._regional_access_boundary_expiry + == source_creds._regional_access_boundary_expiry ) + assert ( + target_creds._regional_access_boundary_cooldown_expiry + == source_creds._regional_access_boundary_cooldown_expiry + ) + + def test_with_trust_boundary_deprecation_warning(self): + creds = CredentialsImpl() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + creds.with_trust_boundary({"encodedLocations": "0xABC"}) + assert len(w) == 1 + assert issubclass(w[-1].category, DeprecationWarning) + assert "with_trust_boundary" in str(w[-1].message) + + def test_old_environment_variable_deprecation_warning(self): + creds = CredentialsImpl() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with mock.patch.dict( + os.environ, {"GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED": "true"} + ): + assert creds._is_regional_access_boundary_lookup_required() + assert len(w) == 1 + assert issubclass(w[-1].category, DeprecationWarning) + assert "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED" in str(w[-1].message) diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 2fa64361d..d6da7d9c5 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -128,8 +128,7 @@ class TestCredentials(object): "status": "INVALID_ARGUMENT", } } - NO_OP_TRUST_BOUNDARY = {"locations": [], "encodedLocations": "0x0"} - VALID_TRUST_BOUNDARY = { + VALID_REGIONAL_ACCESS_BOUNDARY = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEXSA", } @@ -158,9 +157,9 @@ def make_credentials( service_account_impersonation_url=None, service_account_impersonation_options={}, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, + regional_access_boundary=None, ): - return CredentialsImpl( + creds = CredentialsImpl( audience=cls.AUDIENCE, subject_token_type=cls.SUBJECT_TOKEN_TYPE, token_url=cls.TOKEN_URL, @@ -174,8 +173,10 @@ def make_credentials( scopes=scopes, default_scopes=default_scopes, universe_domain=universe_domain, - trust_boundary=trust_boundary, ) + if regional_access_boundary: + creds = creds.with_regional_access_boundary(regional_access_boundary) + return creds @classmethod def make_workforce_pool_credentials( @@ -187,9 +188,9 @@ def make_workforce_pool_credentials( default_scopes=None, service_account_impersonation_url=None, workforce_pool_user_project=None, - trust_boundary=None, + regional_access_boundary=None, ): - return CredentialsImpl( + creds = CredentialsImpl( audience=cls.WORKFORCE_AUDIENCE, subject_token_type=cls.WORKFORCE_SUBJECT_TOKEN_TYPE, token_url=cls.TOKEN_URL, @@ -201,8 +202,10 @@ def make_workforce_pool_credentials( scopes=scopes, default_scopes=default_scopes, workforce_pool_user_project=workforce_pool_user_project, - trust_boundary=trust_boundary, ) + if regional_access_boundary: + creds = creds.with_regional_access_boundary(regional_access_boundary) + return creds @classmethod def make_mock_request( @@ -435,7 +438,6 @@ def test_with_scopes_full_options_propagated(self): scopes=["email"], default_scopes=["default2"], universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_with_token_uri(self): @@ -524,9 +526,7 @@ def test_with_quota_project_full_options_propagated(self): scopes=self.SCOPES, default_scopes=["default1"], universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) - # Confirm with_quota_project sets the correct quota project after # initialization. assert new_cred.quota_project_id == "project-foo" @@ -719,174 +719,29 @@ def test_refresh_without_client_auth_success( assert not credentials.expired assert credentials.token == response["access_token"] - @mock.patch("google.auth.external_account.Credentials._lookup_trust_boundary") - def test_refresh_skips_trust_boundary_lookup_when_disabled( - self, mock_lookup_trust_boundary - ): - credentials = self.make_credentials() - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == self.SUCCESS_RESPONSE["access_token"] - mock_lookup_trust_boundary.assert_not_called() - headers_applied = {} - credentials.apply(headers_applied) - assert "x-allowed-locations" not in headers_applied - - def test_refresh_skips_sending_allowed_locations_header_with_trust_boundary(self): - # This test verifies that the x-allowed-locations header is not sent with - # the STS request even if a trust boundary is cached. - trust_boundary_value = {"encodedLocations": "0x12345"} - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", - } - request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": self.AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "subject_token": "subject_token_0", - "subject_token_type": self.SUBJECT_TOKEN_TYPE, - } - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - # Set a cached trust boundary. - credentials._trust_boundary = trust_boundary_value - - with mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - self.assert_token_request_kwargs(request.call_args[1], headers, request_data) - - def test_refresh_on_impersonated_credential_skips_parent_trust_boundary_lookup( + def test_refresh_propagates_regional_access_boundary_to_impersonated_credential( self, ): - # This test verifies that the top-level impersonating credential - # does not perform a trust boundary lookup. - request = self.make_mock_request( - status=http_client.OK, - data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data={ - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": "2025-01-01T00:00:00Z", - }, - ) - credentials = self.make_credentials( - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL - ) - - with mock.patch.object( - credentials, "_refresh_trust_boundary", autospec=True - ) as mock_refresh_trust_boundary: - credentials.refresh(request) - - mock_refresh_trust_boundary.assert_not_called() - - def test_refresh_fetches_no_op_trust_boundary(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value=self.NO_OP_TRUST_BOUNDARY, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "" - - def test_refresh_skips_lookup_with_cached_no_op_boundary(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - credentials._trust_boundary = self.NO_OP_TRUST_BOUNDARY - - with mock.patch.object( - credentials, "_lookup_trust_boundary" - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_not_called() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "" - - def test_refresh_fails_on_lookup_failure_with_no_cache(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - side_effect=exceptions.RefreshError("Lookup failed"), - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), pytest.raises( - exceptions.RefreshError, match="Lookup failed" - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - - def test_refresh_uses_cached_boundary_on_lookup_failure(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - credentials._trust_boundary = {"encodedLocations": "0x123"} - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - side_effect=exceptions.RefreshError("Lookup failed"), - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "0x123" - - def test_refresh_propagates_trust_boundary_to_impersonated_credential(self): request = self.make_mock_request( status=http_client.OK, data=self.SUCCESS_RESPONSE ) credentials = self.make_credentials( service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, - trust_boundary=self.VALID_TRUST_BOUNDARY, + regional_access_boundary=self.VALID_REGIONAL_ACCESS_BOUNDARY, ) impersonated_creds_mock = mock.Mock() - impersonated_creds_mock._trust_boundary = self.VALID_TRUST_BOUNDARY + impersonated_creds_mock.with_regional_access_boundary.return_value = ( + impersonated_creds_mock + ) with mock.patch( "google.auth.external_account.impersonated_credentials.Credentials", return_value=impersonated_creds_mock, ) as mock_impersonated_creds, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): credentials.refresh(request) @@ -897,19 +752,23 @@ def test_refresh_propagates_trust_boundary_to_impersonated_credential(self): quota_project_id=mock.ANY, iam_endpoint_override=mock.ANY, lifetime=mock.ANY, - trust_boundary=self.VALID_TRUST_BOUNDARY, ) - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + impersonated_creds_mock.with_regional_access_boundary.assert_called_once_with( + self.VALID_REGIONAL_ACCESS_BOUNDARY + ) + assert ( + credentials._regional_access_boundary == self.VALID_REGIONAL_ACCESS_BOUNDARY + ) - def test_build_trust_boundary_lookup_url_workload(self): + def test_build_regional_access_boundary_lookup_url_workload(self): credentials = self.make_credentials() expected_url = "https://iamcredentials.googleapis.com/v1/projects/123456/locations/global/workloadIdentityPools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url + assert credentials._build_regional_access_boundary_lookup_url() == expected_url - def test_build_trust_boundary_lookup_url_workforce(self): + def test_build_regional_access_boundary_lookup_url_workforce(self): credentials = self.make_workforce_pool_credentials() expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url + assert credentials._build_regional_access_boundary_lookup_url() == expected_url @pytest.mark.parametrize( "audience", @@ -919,57 +778,11 @@ def test_build_trust_boundary_lookup_url_workforce(self): "//iam.googleapis.com/locations/global/workforcsePools//providers/provider-id", ], ) - def test_build_trust_boundary_lookup_url_invalid_audience(self, audience): + def test_build_regional_access_boundary_lookup_url_invalid_audience(self, audience): credentials = self.make_credentials() credentials._audience = audience with pytest.raises(exceptions.InvalidValue, match="Invalid audience format."): - credentials._build_trust_boundary_lookup_url() - - def test_refresh_fetches_trust_boundary_workload(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value=self.VALID_TRUST_BOUNDARY, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert ( - headers["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - - def test_refresh_fetches_trust_boundary_workforce(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_workforce_pool_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value=self.VALID_TRUST_BOUNDARY, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert ( - headers["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) + credentials._build_regional_access_boundary_lookup_url() @mock.patch( "google.auth.metrics.python_and_auth_lib_version", @@ -1972,34 +1785,15 @@ def test_before_request_expired(self, utcnow): "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) } - def test_refresh_impersonation_trust_boundary(self): - request = self.make_mock_request( - status=http_client.OK, - data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data={ - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": "2025-01-01T00:00:00Z", - }, + def test_with_regional_access_boundary(self): + credentials = self.make_credentials() + new_credentials = credentials.with_regional_access_boundary( + self.VALID_REGIONAL_ACCESS_BOUNDARY ) - credentials = self.make_credentials( - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL + assert ( + new_credentials._regional_access_boundary + == self.VALID_REGIONAL_ACCESS_BOUNDARY ) - impersonated_creds_mock = mock.Mock() - impersonated_creds_mock._trust_boundary = self.VALID_TRUST_BOUNDARY - - with mock.patch( - "google.auth.external_account.impersonated_credentials.Credentials", - return_value=impersonated_creds_mock, - ): - credentials.refresh(request) - - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - - def test_with_trust_boundary(self): - credentials = self.make_credentials() - new_credentials = credentials.with_trust_boundary(self.VALID_TRUST_BOUNDARY) - assert new_credentials._trust_boundary == self.VALID_TRUST_BOUNDARY @mock.patch("google.auth._helpers.utcnow") def test_before_request_impersonation_expired(self, utcnow): diff --git a/tests/test_external_account_authorized_user.py b/tests/test_external_account_authorized_user.py index 0a54af56d..7c437aa78 100644 --- a/tests/test_external_account_authorized_user.py +++ b/tests/test_external_account_authorized_user.py @@ -16,6 +16,7 @@ import http.client as http_client import json import os +import warnings import mock import pytest # type: ignore @@ -557,14 +558,16 @@ def test_with_universe_domain(self): assert new_creds._quota_project_id == QUOTA_PROJECT_ID assert new_creds.universe_domain == FAKE_UNIVERSE_DOMAIN - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): creds = self.make_credentials( token=ACCESS_TOKEN, expiry=NOW, revoke_url=REVOKE_URL, quota_project_id=QUOTA_PROJECT_ID, ) - new_creds = creds.with_trust_boundary({"encodedLocations": "new_boundary"}) + new_creds = creds.with_regional_access_boundary( + {"encodedLocations": "new_boundary"} + ) assert new_creds._audience == creds._audience assert new_creds._refresh_token_val == creds.refresh_token assert new_creds._token_url == creds._token_url @@ -575,7 +578,9 @@ def test_with_trust_boundary(self): assert new_creds.expiry == creds.expiry assert new_creds._revoke_url == creds._revoke_url assert new_creds._quota_project_id == QUOTA_PROJECT_ID - assert new_creds._trust_boundary == {"encodedLocations": "new_boundary"} + assert new_creds._regional_access_boundary == { + "encodedLocations": "new_boundary" + } def test_from_file_required_options_only(self, tmpdir): from_creds = self.make_credentials() @@ -621,28 +626,7 @@ def test_from_file_full_options(self, tmpdir): assert creds._revoke_url == REVOKE_URL assert creds._quota_project_id == QUOTA_PROJECT_ID - def test_refresh_fetches_trust_boundary(self): - request = self.make_mock_request( - status=http_client.OK, - data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value={"encodedLocations": "0x123"}, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "0x123" - - def test_refresh_skips_trust_boundary_lookup_when_disabled(self): + def test_refresh_skips_regional_access_boundary_lookup_when_disabled(self): request = self.make_mock_request( status=http_client.OK, data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, @@ -650,7 +634,7 @@ def test_refresh_skips_trust_boundary_lookup_when_disabled(self): credentials = self.make_credentials() with mock.patch.object( - credentials, "_lookup_trust_boundary" + credentials, "_lookup_regional_access_boundary" ) as mock_lookup, mock.patch.dict(os.environ, {}, clear=True): credentials.refresh(request) @@ -659,10 +643,10 @@ def test_refresh_skips_trust_boundary_lookup_when_disabled(self): credentials.apply(headers) assert "x-allowed-locations" not in headers - def test_build_trust_boundary_lookup_url(self): + def test_build_regional_access_boundary_lookup_url(self): credentials = self.make_credentials() expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url + assert credentials._build_regional_access_boundary_lookup_url() == expected_url @pytest.mark.parametrize( "audience", @@ -673,12 +657,21 @@ def test_build_trust_boundary_lookup_url(self): "//iam.googleapis.com/workforcePools/POOL_ID/providers/PROVIDER_ID", ], ) - def test_build_trust_boundary_lookup_url_invalid_audience(self, audience): + def test_build_regional_access_boundary_lookup_url_invalid_audience(self, audience): credentials = self.make_credentials(audience=audience) with pytest.raises(exceptions.InvalidValue): - credentials._build_trust_boundary_lookup_url() + credentials._build_regional_access_boundary_lookup_url() - def test_build_trust_boundary_lookup_url_different_universe(self): + def test_build_regional_access_boundary_lookup_url_different_universe(self): credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) expected_url = "https://iamcredentials.fake-universe-domain/v1/locations/global/workforcePools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url + assert credentials._build_regional_access_boundary_lookup_url() == expected_url + + def test_with_trust_boundary_deprecation_warning(self): + creds = self.make_credentials() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + creds.with_trust_boundary({"encodedLocations": "new_boundary"}) + assert len(w) == 1 + assert issubclass(w[-1].category, DeprecationWarning) + assert "with_trust_boundary" in str(w[-1].message) diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index dbbdbf53a..ec0f5074c 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -506,7 +506,6 @@ def test_from_info_full_options(self, mock_init): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -536,7 +535,6 @@ def test_from_info_required_options_only(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -568,7 +566,6 @@ def test_from_info_supplier(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -599,7 +596,6 @@ def test_from_info_workforce_pool(self, mock_init): quota_project_id=None, workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -636,7 +632,6 @@ def test_from_file_full_options(self, mock_init, tmpdir): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -667,7 +662,6 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -699,7 +693,6 @@ def test_from_file_workforce_pool(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_constructor_nonworkforce_with_workforce_pool_user_project(self): diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 2cfc05bef..25da70f94 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -17,6 +17,7 @@ import http.client as http_client import json import os +import warnings import mock import pytest # type: ignore @@ -129,21 +130,19 @@ class TestImpersonatedCredentials(object): # Because Python 2.7: DELEGATES = [] # type: ignore LIFETIME = 3600 - NO_OP_TRUST_BOUNDARY = { - "locations": auth_credentials.NO_OP_TRUST_BOUNDARY_LOCATIONS, - "encodedLocations": auth_credentials.NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS, - } - VALID_TRUST_BOUNDARY = { + VALID_REGIONAL_ACCESS_BOUNDARY = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEX", } - EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = ( + EXPECTED_REGIONAL_ACCESS_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = ( "https://iamcredentials.googleapis.com/v1/projects/-" "/serviceAccounts/impersonated@project.iam.gserviceaccount.com/allowedLocations" ) FAKE_UNIVERSE_DOMAIN = "universe.foo" SOURCE_CREDENTIALS = service_account.Credentials( - SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI, trust_boundary=NO_OP_TRUST_BOUNDARY + SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI + ).with_regional_access_boundary( + {"locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEX"} ) USER_SOURCE_CREDENTIALS = credentials.Credentials(token="ABCDE") IAM_ENDPOINT_OVERRIDE = ( @@ -158,10 +157,10 @@ def make_credentials( target_principal=TARGET_PRINCIPAL, subject=None, iam_endpoint_override=None, - trust_boundary=None, # Align with Credentials class default + regional_access_boundary=None, # Align with Credentials class default ): - return Credentials( + creds = Credentials( source_credentials=source_credentials, target_principal=target_principal, target_scopes=self.TARGET_SCOPES, @@ -169,23 +168,59 @@ def make_credentials( lifetime=lifetime, subject=subject, iam_endpoint_override=iam_endpoint_override, - trust_boundary=trust_boundary, ) + if regional_access_boundary: + creds = creds.with_regional_access_boundary(regional_access_boundary) + return creds + + def test_build_regional_access_boundary_lookup_url(self): + credentials = self.make_credentials() + expected_url = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + "/serviceAccounts/{}/allowedLocations".format(self.TARGET_PRINCIPAL) + ) + url = credentials._build_regional_access_boundary_lookup_url() + assert url == expected_url + + def test_build_regional_access_boundary_lookup_url_non_default_universe(self): + # Create a copy of the service account info and set the universe_domain. + info = SERVICE_ACCOUNT_INFO.copy() + info["universe_domain"] = "my-universe.com" + source_creds = service_account.Credentials.from_service_account_info(info) + credentials = self.make_credentials(source_credentials=source_creds) + expected_url = ( + "https://iamcredentials.my-universe.com/v1/projects/-" + "/serviceAccounts/{}/allowedLocations".format(self.TARGET_PRINCIPAL) + ) + url = credentials._build_regional_access_boundary_lookup_url() + assert url == expected_url + + def test_build_regional_access_boundary_lookup_url_no_email(self): + credentials = self.make_credentials(target_principal=None) + with pytest.raises(ValueError) as excinfo: + credentials._build_regional_access_boundary_lookup_url() + assert "Service account email is required" in str(excinfo.value) def test_from_impersonated_service_account_info(self): - credentials = impersonated_credentials.Credentials.from_impersonated_service_account_info( - IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO + ) ) assert isinstance(credentials, impersonated_credentials.Credentials) - def test_from_impersonated_service_account_info_with_trust_boundary(self): + def test_from_impersonated_service_account_info_with_regional_access_boundary(self): info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) - info["trust_boundary"] = self.VALID_TRUST_BOUNDARY - credentials = impersonated_credentials.Credentials.from_impersonated_service_account_info( - info + info["regional_access_boundary"] = self.VALID_REGIONAL_ACCESS_BOUNDARY + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) ) assert isinstance(credentials, impersonated_credentials.Credentials) - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + assert ( + credentials._regional_access_boundary == self.VALID_REGIONAL_ACCESS_BOUNDARY + ) def test_from_impersonated_service_account_info_with_invalid_source_credentials_type( self, @@ -216,8 +251,10 @@ def test_from_impersonated_service_account_info_with_invalid_impersonation_url( def test_from_impersonated_service_account_info_with_scopes(self): info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) info["scopes"] = ["scope1", "scope2"] - credentials = impersonated_credentials.Credentials.from_impersonated_service_account_info( - info + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info + ) ) assert credentials._target_scopes == ["scope1", "scope2"] @@ -225,8 +262,10 @@ def test_from_impersonated_service_account_info_with_scopes_param(self): info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) info["scopes"] = ["scope_from_info_1", "scope_from_info_2"] scopes_param = ["scope_from_param_1", "scope_from_param_2"] - credentials = impersonated_credentials.Credentials.from_impersonated_service_account_info( - info, scopes=scopes_param + credentials = ( + impersonated_credentials.Credentials.from_impersonated_service_account_info( + info, scopes=scopes_param + ) ) assert credentials._target_scopes == scopes_param @@ -304,72 +343,8 @@ def test_token_usage_metrics(self): assert headers["authorization"] == "Bearer token" assert headers["x-goog-api-client"] == "cred-type/imp" - @pytest.mark.parametrize("use_data_bytes", [True, False]) - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_success( - self, mock_lookup_trust_boundary, use_data_bytes, mock_donor_credentials - ): - # Start with no boundary. - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - - request = self.make_request( - data=json.dumps(response_body), - status=http_client.OK, - use_data_bytes=use_data_bytes, - ) - - # Mock the trust boundary lookup to return a valid value. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - assert credentials.valid - assert not credentials.expired - assert ( - request.call_args.kwargs["headers"]["x-goog-api-client"] - == ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE - ) - - # Verify that the x-allowed-locations header from the source credential - # was applied. The source credential has a NO_OP boundary, so the - # header should be an empty string. - request_kwargs = request.call_args[1] - assert "headers" in request_kwargs - assert "x-allowed-locations" in request_kwargs["headers"] - assert request_kwargs["headers"]["x-allowed-locations"] == "" - - # Verify trust boundary was set. - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - - # Verify the mock was called with the correct URL. - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - - # Verify x-allowed-locations header is set correctly by apply(). - headers_applied = {} - credentials.apply(headers_applied) - assert ( - headers_applied["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - - def test_refresh_source_creds_no_trust_boundary(self): - # Use a source credential that does not support trust boundaries. + def test_refresh_source_creds_no_regional_access_boundary(self): + # Use a source credential that does not support Regional Access Boundaries. source_credentials = credentials.Credentials(token="source_token") creds = self.make_credentials(source_credentials=source_credentials) token = "impersonated_token" @@ -386,81 +361,13 @@ def test_refresh_source_creds_no_trust_boundary(self): creds.refresh(request) # Verify that the x-allowed-locations header was NOT applied because - # the source credential does not support trust boundaries. + # the source credential does not support Regional Access Boundaries. request_kwargs = request.call_args[1] assert "x-allowed-locations" not in request_kwargs["headers"] - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_trust_boundary_lookup_fails_no_cache( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - # Start with no trust boundary - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - # Mock the trust boundary lookup to raise an error - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), pytest.raises(exceptions.RefreshError) as excinfo: - credentials.refresh(request) - - assert "Lookup failed" in str(excinfo.value) - assert credentials._trust_boundary is None # Still no trust boundary - mock_lookup_trust_boundary.assert_called_once() - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_fetches_no_op_trust_boundary( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - # Start with no trust boundary - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - mock_lookup_trust_boundary.return_value = ( - self.NO_OP_TRUST_BOUNDARY - ) # Mock returns NO_OP - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_skips_trust_boundary_lookup_non_default_universe( - self, mock_lookup_trust_boundary + @mock.patch("google.oauth2._client._lookup_regional_access_boundary") + def test_refresh_skips_regional_access_boundary_lookup_non_default_universe( + self, mock_lookup_rab ): # Create source credentials with a non-default universe domain source_credentials = service_account.Credentials( @@ -483,98 +390,20 @@ def test_refresh_skips_trust_boundary_lookup_non_default_universe( ) with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + { + environment_vars.GOOGLE_AUTH_REGIONAL_ACCESS_BOUNDARY_ENABLE_EXPERIMENT: "true" + }, ): credentials.refresh(request) - # Ensure trust boundary lookup was not called - mock_lookup_trust_boundary.assert_not_called() + # Ensure Regional Access Boundary lookup was not called + mock_lookup_rab.assert_not_called() # Verify that x-allowed-locations header is not set by apply() headers_applied = {} credentials.apply(headers_applied) assert "x-allowed-locations" not in headers_applied - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - credentials = self.make_credentials( - lifetime=None, trust_boundary=self.NO_OP_TRUST_BOUNDARY - ) # Start with NO_OP - token = "token" - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - # Verify trust boundary remained NO_OP - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - - # Lookup should be skipped - mock_lookup_trust_boundary.assert_not_called() - - # Verify that an empty header was added. - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_trust_boundary_lookup_fails_with_cached_data2( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - # Start with no trust boundary - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - # First refresh: Successfully fetch a valid trust boundary. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - assert credentials.valid - # Verify trust boundary was set. - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once() - - # Second refresh: Mock lookup to fail, but expect cached data to be preserved. - mock_lookup_trust_boundary.reset_mock() - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once() - @pytest.mark.parametrize("use_data_bytes", [True, False]) def test_refresh_with_subject_success(self, use_data_bytes, mock_dwd_credentials): credentials = self.make_credentials(subject="test@email.com", lifetime=None) @@ -952,13 +781,13 @@ def test_with_scopes(self): assert credentials.requires_scopes is False assert credentials._target_scopes == ["fake_scope1", "fake_scope2"] - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): credentials = self.make_credentials() new_boundary = {"encodedLocations": "new_boundary"} - new_credentials = credentials.with_trust_boundary(new_boundary) + new_credentials = credentials.with_regional_access_boundary(new_boundary) assert new_credentials is not credentials - assert new_credentials._trust_boundary == new_boundary + assert new_credentials._regional_access_boundary == new_boundary # The source credentials should be a copy, not the same object. # But they should be functionally equivalent. assert ( @@ -975,11 +804,11 @@ def test_with_trust_boundary(self): ) assert new_credentials._target_principal == credentials._target_principal - def test_build_trust_boundary_lookup_url_no_email(self): + def test_build_regional_access_boundary_lookup_url_no_email(self): credentials = self.make_credentials(target_principal=None) with pytest.raises(ValueError) as excinfo: - credentials._build_trust_boundary_lookup_url() + credentials._build_regional_access_boundary_lookup_url() assert "Service account email is required" in str(excinfo.value) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index d15ebb88b..d6c8c1ee2 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -32,8 +32,10 @@ SERVICE_ACCOUNT_IMPERSONATION_URL_BASE = ( "https://us-east1-iamcredentials.googleapis.com" ) -SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE = "/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( - SERVICE_ACCOUNT_EMAIL +SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE = ( + "/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + SERVICE_ACCOUNT_EMAIL + ) ) SERVICE_ACCOUNT_IMPERSONATION_URL = ( SERVICE_ACCOUNT_IMPERSONATION_URL_BASE + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE @@ -272,7 +274,6 @@ def test_from_info_full_options(self, mock_init): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -301,7 +302,6 @@ def test_from_info_required_options_only(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -337,7 +337,6 @@ def test_from_file_full_options(self, mock_init, tmpdir): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -367,7 +366,6 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_constructor_invalid_options(self): @@ -1108,7 +1106,7 @@ def test_retrieve_subject_token_non_workforce_fail_interactive_mode(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_fail_on_validation_missing_interactive_timeout( - self + self, ): CREDENTIAL_SOURCE_EXECUTABLE = { "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, diff --git a/tests/transport/test_requests.py b/tests/transport/test_requests.py index 0da3e36d9..37c0f8da1 100644 --- a/tests/transport/test_requests.py +++ b/tests/transport/test_requests.py @@ -141,6 +141,20 @@ def refresh(self, request): super(TimeTickCredentialsStub, self).refresh(requests) +class CredentialsWithRegionalAccessBoundaryStub( + google.auth.credentials.CredentialsWithRegionalAccessBoundary, CredentialsStub +): + def __init__(self, token="token"): + super(CredentialsWithRegionalAccessBoundaryStub, self).__init__(token=token) + self._regional_access_boundary = {"encodedLocations": "initial_value"} + + def _refresh_token(self, request): + return super(CredentialsWithRegionalAccessBoundaryStub, self).refresh(request) + + def _build_regional_access_boundary_lookup_url(self): + return "http://metadata.google.internal" + + class AdapterStub(requests.adapters.BaseAdapter): def __init__(self, responses, headers=None): super(AdapterStub, self).__init__() @@ -286,6 +300,42 @@ def test_request_refresh(self): assert adapter.requests[1].url == self.TEST_URL assert adapter.requests[1].headers["authorization"] == "token1" + def test_request_stale_regional_access_boundary(self): + credentials = mock.Mock(wraps=CredentialsWithRegionalAccessBoundaryStub()) + final_response = make_response(status=http_client.OK) + # First request will fail with a stale boundary error, the second will succeed. + adapter = AdapterStub( + [ + make_response( + status=http_client.BAD_REQUEST, + data=b"stale regional access boundary", + ), + final_response, + ] + ) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, + ) + authed_session.mount(self.TEST_URL, adapter) + + result = authed_session.request("GET", self.TEST_URL) + + # Check that the final result is the successful one. + assert result == final_response + # Check that we made two requests. + assert len(adapter.requests) == 2 + # Check that the stale boundary handler was called. + credentials.handle_stale_regional_access_boundary.assert_called_once() + + # Check the headers for the first request. + assert adapter.requests[0].url == self.TEST_URL + assert "initial_value" in adapter.requests[0].headers.get("x-allowed-locations") + + # Check the headers for the retried request. + assert adapter.requests[1].url == self.TEST_URL + assert "x-allowed-locations" not in adapter.requests[1].headers + def test_request_max_allowed_time_timeout_error(self, frozen_time): tick_one_second = functools.partial( frozen_time.tick, delta=datetime.timedelta(seconds=1.0) From e482e5384676bd907c56fb0c70e1fb7a2548ed75 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:37:57 -0800 Subject: [PATCH 02/11] fix minor issues --- google/auth/_regional_access_boundary_utils.py | 8 +++++--- google/auth/compute_engine/credentials.py | 2 +- google/auth/credentials.py | 4 ---- google/auth/impersonated_credentials.py | 9 ++++----- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/google/auth/_regional_access_boundary_utils.py b/google/auth/_regional_access_boundary_utils.py index 303a5e021..8bbce7b93 100644 --- a/google/auth/_regional_access_boundary_utils.py +++ b/google/auth/_regional_access_boundary_utils.py @@ -22,6 +22,7 @@ class _RegionalAccessBoundaryRefreshThread(threading.Thread): def __init__(self, credentials, request): super(_RegionalAccessBoundaryRefreshThread, self).__init__() + self.daemon = True self._credentials = credentials self._request = request @@ -63,15 +64,16 @@ def run(self): _LOGGER.warning( "Asynchronous Regional Access Boundary lookup failed. Entering cooldown." ) + self._credentials._regional_access_boundary_cooldown_expiry = ( + _helpers.utcnow() + + self._credentials._current_rab_cooldown_duration + ) new_cooldown_duration = ( self._credentials._current_rab_cooldown_duration * 2 ) self._credentials._current_rab_cooldown_duration = min( new_cooldown_duration, MAX_REGIONAL_ACCESS_BOUNDARY_COOLDOWN ) - self._credentials._regional_access_boundary_cooldown_expiry = ( - _helpers.utcnow() + self._credentials._current_rab_cooldown_duration - ) # If the proactive refresh failed, clear any existing expired RAB data. # This ensures we don't continue using stale data. self._credentials._regional_access_boundary = None diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 1071f5798..8ce0c41df 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -172,7 +172,7 @@ def _build_regional_access_boundary_lookup_url(self): return ( _constants._SERVICE_ACCOUNT_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( - self.service_account_email + service_account_email=self.service_account_email ) ) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 40db38cda..56b710afc 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -545,10 +545,6 @@ def _build_regional_access_boundary_lookup_url(self): ) -# For backward compatibility. -CredentialsWithTrustBoundary = CredentialsWithRegionalAccessBoundary - - class AnonymousCredentials(Credentials): """Credentials that do not provide any authentication information. diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 000bdf948..f134334f0 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -39,6 +39,7 @@ from google.auth import iam from google.auth import jwt from google.auth import metrics +from google.auth import _constants from google.oauth2 import _client import logging @@ -50,9 +51,7 @@ _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" -_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT = ( - "https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations" -) + _SOURCE_CREDENTIAL_AUTHORIZED_USER_TYPE = "authorized_user" _SOURCE_CREDENTIAL_SERVICE_ACCOUNT_TYPE = "service_account" @@ -361,8 +360,8 @@ def _build_regional_access_boundary_lookup_url(self): "Service account email is required to build the Regional Access Boundary lookup URL for impersonated credentials." ) return None - return _REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( - self.universe_domain, self.service_account_email + return _constants._SERVICE_ACCOUNT_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( + service_account_email=self.service_account_email ) def sign_bytes(self, message): From 21196fd1f8a73c7f5aa52a968bbf2c5edc9b179c Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:13:12 -0800 Subject: [PATCH 03/11] Remove manual override and reactie reftesh --- google/auth/credentials.py | 48 +++---------------- google/auth/external_account.py | 2 +- .../auth/external_account_authorized_user.py | 2 +- google/auth/impersonated_credentials.py | 8 ++-- google/auth/transport/requests.py | 35 -------------- google/oauth2/service_account.py | 2 +- 6 files changed, 15 insertions(+), 82 deletions(-) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 56b710afc..6fe862811 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -294,7 +294,7 @@ def with_universe_domain(self, universe_domain): class CredentialsWithRegionalAccessBoundary(Credentials): - """Abstract base for credentials supporting ``with_regional_access_boundary`` factory""" + """Abstract base for credentials supporting regional access boundary configuration.""" def __init__(self, *args, **kwargs): super(CredentialsWithRegionalAccessBoundary, self).__init__(*args, **kwargs) @@ -323,28 +323,16 @@ def _perform_refresh_token(self, request): """ raise NotImplementedError("_perform_refresh_token must be implemented") - def with_regional_access_boundary(self, regional_access_boundary): + def _with_regional_access_boundary(self, regional_access_boundary): """Returns a copy of these credentials with a modified Regional Access Boundary. - - This method allows for manually providing the Regional Access Boundary - information, which will be cached with a 6-hour lifetime. This bypasses - the initial asynchronous lookup. After the cache expires, the library - will trigger a background refresh on the next request. - + This is an internal method used by credential factory methods (e.g., from_info) + to seed the RAB cache. The provided value is cached with the default TTL. Args: - regional_access_boundary (Mapping[str, str]): The Regional Access Boundary - to use for the credential. This should be a map with an - "encodedLocations" key that maps to a hex string. Optionally, - it can also contain a "locations" key with a list of GCP regions. - Example: `{"locations": ["us-central1"], "encodedLocations": "0xA30"}` - + regional_access_boundary (dict): Must contain an "encodedLocations" key. Returns: - google.auth.credentials.Credentials: A new credentials instance - with the specified Regional Access Boundary. - + google.auth.credentials.Credentials: A new credentials instance. Raises: - google.auth.exceptions.InvalidValue: If `regional_access_boundary` - is not a dictionary or does not contain the "encodedLocations" key. + google.auth.exceptions.InvalidValue: If the input is malformed. """ if ( not isinstance(regional_access_boundary, dict) @@ -377,28 +365,6 @@ def _copy_regional_access_boundary_state(self, target): # Create a new lock for the target instance to ensure independent thread-safety. target._stale_boundary_lock = threading.Lock() - def handle_stale_regional_access_boundary(self, request): - """Handles a stale regional access boundary error. - This method is thread-safe and will only initiate a single refresh - even if called concurrently. - Args: - request (google.auth.transport.Request): The object used to make - HTTP requests. - """ - with self._stale_boundary_lock: - # Another thread might have already handled the stale boundary. - if self._regional_access_boundary is None: - return - - _LOGGER.info("Stale regional access boundary detected. Refreshing.") - - # Clear the cached boundary. - self._regional_access_boundary = None - self._regional_access_boundary_expiry = None - - # Start the background refresh. - self._regional_access_boundary_refresh_manager.start_refresh(self, request) - def _maybe_start_regional_access_boundary_refresh(self, request, url): """ Starts a background thread to refresh the Regional Access Boundary if needed. diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 664fe551b..5f06daa4d 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -675,7 +675,7 @@ def from_info(cls, info, **kwargs): regional_access_boundary = info.get("regional_access_boundary") if regional_access_boundary: - initial_creds = initial_creds.with_regional_access_boundary( + initial_creds = initial_creds._with_regional_access_boundary( regional_access_boundary ) diff --git a/google/auth/external_account_authorized_user.py b/google/auth/external_account_authorized_user.py index c77da8813..056fe221a 100644 --- a/google/auth/external_account_authorized_user.py +++ b/google/auth/external_account_authorized_user.py @@ -441,7 +441,7 @@ def from_info(cls, info, **kwargs): regional_access_boundary = info.get("regional_access_boundary") if regional_access_boundary: - initial_creds = initial_creds.with_regional_access_boundary( + initial_creds = initial_creds._with_regional_access_boundary( regional_access_boundary ) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index f134334f0..2942b3eb6 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -360,8 +360,10 @@ def _build_regional_access_boundary_lookup_url(self): "Service account email is required to build the Regional Access Boundary lookup URL for impersonated credentials." ) return None - return _constants._SERVICE_ACCOUNT_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( - service_account_email=self.service_account_email + return ( + _constants._SERVICE_ACCOUNT_REGIONAL_ACCESS_BOUNDARY_LOOKUP_ENDPOINT.format( + service_account_email=self.service_account_email + ) ) def sign_bytes(self, message): @@ -530,7 +532,7 @@ def from_impersonated_service_account_info(cls, info, scopes=None): regional_access_boundary = info.get("regional_access_boundary") if regional_access_boundary: - initial_creds = initial_creds.with_regional_access_boundary( + initial_creds = initial_creds._with_regional_access_boundary( regional_access_boundary ) diff --git a/google/auth/transport/requests.py b/google/auth/transport/requests.py index 37946c4f7..9735762c4 100644 --- a/google/auth/transport/requests.py +++ b/google/auth/transport/requests.py @@ -476,18 +476,6 @@ def configure_mtls_channel(self, client_cert_callback=None): new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc - def _is_stale_regional_access_boundary_error(self, response): - """Checks if the response indicates a stale regional access boundary.""" - if response.status_code != 406: - return False - - try: - # The response data is bytes, decode it to a string. - response_text = response.content.decode("utf-8") - return "stale regional access boundary" in response_text.lower() - except (UnicodeDecodeError, AttributeError): - return False - def request( self, method, @@ -531,7 +519,6 @@ def request( # Use a kwarg for this instead of an attribute to maintain # thread-safety. _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0) - _stale_boundary_retried = kwargs.pop("_stale_boundary_retried", False) # Make a copy of the headers. They will be modified by the credentials # and we want to pass the original headers if we recurse. @@ -634,28 +621,6 @@ def request( **kwargs ) - # If the response indicated a stale regional access boundary, clear the - # cached boundary and re-attempt the request. This is only done once. - if ( - self._is_stale_regional_access_boundary_error(response) - and not _stale_boundary_retried - ): - _LOGGER.info( - "Stale regional access boundary detected, clearing and retrying." - ) - self.credentials.handle_stale_regional_access_boundary(auth_request) - # Recurse, passing in the original headers and marking that we have retried. - return self.request( - method, - url, - data=data, - headers=headers, - max_allowed_time=remaining_time, - timeout=timeout, - _stale_boundary_retried=True, - **kwargs - ) - return response @property diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index a96943f0a..56215dd60 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -227,7 +227,7 @@ def _from_signer_and_info(cls, signer, info, **kwargs): ) regional_access_boundary = info.get("regional_access_boundary") if regional_access_boundary: - initial_creds = initial_creds.with_regional_access_boundary( + initial_creds = initial_creds._with_regional_access_boundary( regional_access_boundary ) return initial_creds From f615aeeed00481370a9e27e3b41df0aee9c6e112 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:04:08 -0800 Subject: [PATCH 04/11] Fixing lint issues --- google/auth/_regional_access_boundary_utils.py | 3 +-- google/auth/compute_engine/credentials.py | 4 ++-- google/auth/credentials.py | 10 +++++----- google/auth/external_account.py | 8 +++----- google/auth/external_account_authorized_user.py | 8 +++----- google/auth/impersonated_credentials.py | 6 ++---- google/oauth2/_client.py | 3 +-- google/oauth2/_service_account_async.py | 7 +++++++ google/oauth2/service_account.py | 3 +-- 9 files changed, 25 insertions(+), 27 deletions(-) diff --git a/google/auth/_regional_access_boundary_utils.py b/google/auth/_regional_access_boundary_utils.py index 8bbce7b93..95c154bc8 100644 --- a/google/auth/_regional_access_boundary_utils.py +++ b/google/auth/_regional_access_boundary_utils.py @@ -65,8 +65,7 @@ def run(self): "Asynchronous Regional Access Boundary lookup failed. Entering cooldown." ) self._credentials._regional_access_boundary_cooldown_expiry = ( - _helpers.utcnow() - + self._credentials._current_rab_cooldown_duration + _helpers.utcnow() + self._credentials._current_rab_cooldown_duration ) new_cooldown_duration = ( self._credentials._current_rab_cooldown_duration * 2 diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 8ce0c41df..5d57d5775 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -22,8 +22,6 @@ import datetime import logging -_LOGGER = logging.getLogger(__name__) - from google.auth import _constants from google.auth import _helpers from google.auth import credentials @@ -34,6 +32,8 @@ from google.auth.compute_engine import _metadata from google.oauth2 import _client +_LOGGER = logging.getLogger(__name__) + class Credentials( credentials.Scoped, diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 6fe862811..adaae56e0 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -16,21 +16,18 @@ """Interfaces for credentials.""" import abc -import datetime from enum import Enum import logging import os import threading from urllib.parse import urlparse -import warnings -from google.auth import _exponential_backoff from google.auth import _helpers, environment_vars +from google.auth import _regional_access_boundary_utils from google.auth import exceptions from google.auth import metrics from google.auth._credentials_base import _BaseCredentials from google.auth._refresh_worker import RefreshThreadManager -from google.auth import _regional_access_boundary_utils DEFAULT_UNIVERSE_DOMAIN = "googleapis.com" @@ -326,7 +323,10 @@ def _perform_refresh_token(self, request): def _with_regional_access_boundary(self, regional_access_boundary): """Returns a copy of these credentials with a modified Regional Access Boundary. This is an internal method used by credential factory methods (e.g., from_info) - to seed the RAB cache. The provided value is cached with the default TTL. + to seed the RAB cache when a new boundary is fetched or explicitly provided. + Because this represents a newly acquired boundary, it is granted a fresh + default TTL and any previous cooldowns are cleared. To copy an existing boundary's + state (including its remaining TTL and cooldowns), use `_copy_regional_access_boundary_state`. Args: regional_access_boundary (dict): Must contain an "encodedLocations" key. Returns: diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 5f06daa4d..7ee7a6968 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -34,12 +34,8 @@ import functools import io import json -import re -import warnings - import logging - -_LOGGER = logging.getLogger(__name__) +import re from google.auth import _constants from google.auth import _helpers @@ -50,6 +46,8 @@ from google.oauth2 import sts from google.oauth2 import utils +_LOGGER = logging.getLogger(__name__) + # External account JSON type identifier. _EXTERNAL_ACCOUNT_JSON_TYPE = "external_account" # The token exchange grant_type used for exchanging credentials. diff --git a/google/auth/external_account_authorized_user.py b/google/auth/external_account_authorized_user.py index 056fe221a..77fa92293 100644 --- a/google/auth/external_account_authorized_user.py +++ b/google/auth/external_account_authorized_user.py @@ -36,12 +36,8 @@ import datetime import io import json -import re -import warnings - import logging - -_LOGGER = logging.getLogger(__name__) +import re from google.auth import _constants from google.auth import _helpers @@ -50,6 +46,8 @@ from google.oauth2 import sts from google.oauth2 import utils +_LOGGER = logging.getLogger(__name__) + _EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE = "external_account_authorized_user" diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 2942b3eb6..4d65bfd36 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -30,8 +30,9 @@ from datetime import datetime import http.client as http_client import json -import warnings +import logging +from google.auth import _constants from google.auth import _exponential_backoff from google.auth import _helpers from google.auth import credentials @@ -39,11 +40,8 @@ from google.auth import iam from google.auth import jwt from google.auth import metrics -from google.auth import _constants from google.oauth2 import _client -import logging - _LOGGER = logging.getLogger(__name__) _REFRESH_ERROR = "Unable to acquire impersonated credentials" diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py index 2635d2590..4b5a0c1f7 100644 --- a/google/oauth2/_client.py +++ b/google/oauth2/_client.py @@ -26,9 +26,8 @@ import datetime import http.client as http_client import json -import urllib - import logging +import urllib from google.auth import _exponential_backoff from google.auth import _helpers diff --git a/google/oauth2/_service_account_async.py b/google/oauth2/_service_account_async.py index cfd315a7f..9067542f1 100644 --- a/google/oauth2/_service_account_async.py +++ b/google/oauth2/_service_account_async.py @@ -75,6 +75,13 @@ async def refresh(self, request): self.token = access_token self.expiry = expiry + @_helpers.copy_docstring(credentials_async.Credentials) + async def before_request(self, request, method, url, headers): + await credentials_async.Credentials.before_request( + self, request, method, url, headers + ) + self._maybe_start_regional_access_boundary_refresh(request, url) + class IDTokenCredentials( service_account.IDTokenCredentials, diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 56215dd60..3e4bbdc4c 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -72,6 +72,7 @@ import copy import datetime +import logging from google.auth import _constants from google.auth import _helpers @@ -83,8 +84,6 @@ from google.auth import metrics from google.oauth2 import _client -import logging - _LOGGER = logging.getLogger(__name__) _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds From e5388a755884ad768474378085b7872ed95a69bb Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:32:55 -0800 Subject: [PATCH 05/11] Refactor unit tests --- tests/compute_engine/test_credentials.py | 306 ++--------------- tests/oauth2/test__client.py | 105 +++--- tests/oauth2/test_service_account.py | 269 +-------------- tests/test__helpers.py | 9 +- tests/test_aws.py | 5 - tests/test_credentials.py | 296 ++++++++++++----- tests/test_external_account.py | 313 ++---------------- .../test_external_account_authorized_user.py | 87 +---- tests/test_identity_pool.py | 7 - tests/test_impersonated_credentials.py | 261 +-------------- tests/test_pluggable.py | 4 - 11 files changed, 371 insertions(+), 1291 deletions(-) diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py index ab7d45e04..9ed0865fc 100644 --- a/tests/compute_engine/test_credentials.py +++ b/tests/compute_engine/test_credentials.py @@ -26,7 +26,6 @@ from google.auth import transport from google.auth.compute_engine import credentials from google.auth.transport import requests -from google.oauth2 import _client SAMPLE_ID_TOKEN_EXP = 1584393400 @@ -62,9 +61,8 @@ class TestCredentials(object): credentials = None credentials_with_all_fields = None - VALID_TRUST_BOUNDARY = {"encodedLocations": "valid-encoded-locations"} - NO_OP_TRUST_BOUNDARY = {"encodedLocations": ""} - EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations" + VALID_REGIONAL_ACCESS_BOUNDARY = {"encodedLocations": "valid-encoded-locations"} + EXPECTED_REGIONAL_ACCESS_BOUNDARY_LOOKUP_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations" @pytest.fixture(autouse=True) def credentials_fixture(self): @@ -258,13 +256,13 @@ def test_with_universe_domain(self): assert creds.universe_domain == "universe_domain" assert creds._universe_domain_cached - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): creds = self.credentials_with_all_fields new_boundary = {"encodedLocations": "new_boundary"} - new_creds = creds.with_trust_boundary(new_boundary) + new_creds = creds._with_regional_access_boundary(new_boundary) assert new_creds is not creds - assert new_creds._trust_boundary == new_boundary + assert new_creds._regional_access_boundary == new_boundary assert new_creds._service_account_email == creds._service_account_email assert new_creds._quota_project_id == creds._quota_project_id assert new_creds._scopes == creds._scopes @@ -309,10 +307,10 @@ def test_user_provided_universe_domain(self, get_universe_domain): # domain endpoint. get_universe_domain.assert_not_called() - @mock.patch("google.oauth2._client._lookup_trust_boundary", autospec=True) + @mock.patch("google.oauth2._client._lookup_regional_access_boundary", autospec=True) @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - def test_refresh_trust_boundary_lookup_skipped_if_env_var_not_true( - self, mock_metadata_get, mock_lookup_tb + def test_refresh_regional_access_boundary_lookup_skipped_if_env_var_not_true( + self, mock_metadata_get, mock_lookup_rab ): creds = self.credentials request = mock.Mock() @@ -325,17 +323,18 @@ def test_refresh_trust_boundary_lookup_skipped_if_env_var_not_true( ] with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"} + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"}, ): creds.refresh(request) - mock_lookup_tb.assert_not_called() - assert creds._trust_boundary is None + mock_lookup_rab.assert_not_called() + assert creds._regional_access_boundary is None - @mock.patch("google.oauth2._client._lookup_trust_boundary", autospec=True) + @mock.patch("google.oauth2._client._lookup_regional_access_boundary", autospec=True) @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - def test_refresh_trust_boundary_lookup_skipped_if_env_var_missing( - self, mock_metadata_get, mock_lookup_tb + def test_refresh_regional_access_boundary_lookup_skipped_if_env_var_missing( + self, mock_metadata_get, mock_lookup_rab ): creds = self.credentials request = mock.Mock() @@ -350,234 +349,25 @@ def test_refresh_trust_boundary_lookup_skipped_if_env_var_missing( with mock.patch.dict(os.environ, clear=True): creds.refresh(request) - mock_lookup_tb.assert_not_called() - assert creds._trust_boundary is None - - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - def test_refresh_trust_boundary_lookup_success( - self, mock_metadata_get, mock_lookup_tb - ): - mock_lookup_tb.return_value = { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - creds = self.credentials - request = mock.Mock() - - # The first call to _metadata.get is for service account info, the second - # for the access token, and the third for the universe domain. - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - # from get_universe_domain - "", - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - # Verify _metadata.get was called three times. - assert mock_metadata_get.call_count == 3 - # Verify lookup_trust_boundary was called with correct URL and token - expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" - mock_lookup_tb.assert_called_once_with( - request, expected_url, headers={"authorization": "Bearer mock_token"} - ) - # Verify trust boundary was set - assert creds._trust_boundary == { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - - # Verify x-allowed-locations header is set by apply() - headers_applied = {} - creds.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "0xABC" - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_trust_boundary_lookup_fails_no_cache( - self, mock_lookup_tb, mock_metadata_get - ): - mock_lookup_tb.side_effect = exceptions.RefreshError("Lookup failed") - creds = self.credentials - request = mock.Mock() - - # Mock metadata calls for token, universe domain, and service account info - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - # from get_universe_domain - "", - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - with pytest.raises(exceptions.RefreshError, match="Lookup failed"): - creds.refresh(request) - - assert creds._trust_boundary is None - assert mock_metadata_get.call_count == 3 - mock_lookup_tb.assert_called_once() - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_trust_boundary_lookup_fails_with_cached_data( - self, mock_lookup_tb, mock_metadata_get - ): - # First refresh: Successfully fetch a valid trust boundary. - mock_lookup_tb.return_value = { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token_1", "expires_in": 3600}, - # from get_universe_domain - "", - ] - creds = self.credentials - request = mock.Mock() - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - assert creds._trust_boundary == { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - mock_lookup_tb.assert_called_once() - - # Second refresh: Mock lookup to fail, but expect cached data to be preserved. - mock_lookup_tb.reset_mock() - mock_lookup_tb.side_effect = exceptions.RefreshError("Lookup failed") - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - # This refresh should not raise an error because a cached value exists. - mock_metadata_get.reset_mock() - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token_2", "expires_in": 3600}, - # from get_universe_domain - "", - ] - creds.refresh(request) - - assert creds._trust_boundary == { - "locations": ["us-central1"], - "encodedLocations": "0xABC", - } - mock_lookup_tb.assert_called_once() - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_fetches_no_op_trust_boundary( - self, mock_lookup_tb, mock_metadata_get - ): - mock_lookup_tb.return_value = {"locations": [], "encodedLocations": "0x0"} - creds = self.credentials - request = mock.Mock() - - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - # from get_universe_domain - "", - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - assert creds._trust_boundary == {"locations": [], "encodedLocations": "0x0"} - assert mock_metadata_get.call_count == 3 - expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" - mock_lookup_tb.assert_called_once_with( - request, expected_url, headers={"authorization": "Bearer mock_token"} - ) - # Verify that an empty header was added. - headers_applied = {} - creds.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) - @mock.patch.object(_client, "_lookup_trust_boundary", autospec=True) - def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( - self, mock_lookup_tb, mock_metadata_get - ): - creds = self.credentials - # Use pre-cache universe domain to avoid an extra metadata call. - creds._universe_domain_cached = True - creds._trust_boundary = {"locations": [], "encodedLocations": "0x0"} - request = mock.Mock() - - mock_metadata_get.side_effect = [ - # from _retrieve_info - {"email": "resolved-email@example.com", "scopes": ["scope1"]}, - # from get_service_account_token - {"access_token": "mock_token", "expires_in": 3600}, - ] - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) - - # Verify trust boundary remained NO_OP - assert creds._trust_boundary == {"locations": [], "encodedLocations": "0x0"} - # Lookup should be skipped - mock_lookup_tb.assert_not_called() - # Two metadata calls for token refresh should have happened. - assert mock_metadata_get.call_count == 2 - - # Verify that an empty header was added. - headers_applied = {} - creds.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" + mock_lookup_rab.assert_not_called() + assert creds._regional_access_boundary is None @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True ) - @mock.patch( - "google.auth.compute_engine._metadata.get_universe_domain", autospec=True - ) - def test_build_trust_boundary_lookup_url_default_email( - self, mock_get_universe_domain, mock_get_service_account_info + def test_build_regional_access_boundary_lookup_url_default_email( + self, mock_get_service_account_info ): - # Test with default service account email, which needs resolution - creds = self.credentials - creds._service_account_email = "default" mock_get_service_account_info.return_value = { "email": "resolved-email@example.com" } - mock_get_universe_domain.return_value = "googleapis.com" - - url = creds._build_trust_boundary_lookup_url() + creds = self.credentials + creds._universe_domain_cached = True + url = creds._build_regional_access_boundary_lookup_url() mock_get_service_account_info.assert_called_once_with(mock.ANY, "default") - mock_get_universe_domain.assert_called_once_with(mock.ANY) - assert url == ( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" - ) + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/resolved-email@example.com/allowedLocations" + assert url == expected_url @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True @@ -585,7 +375,7 @@ def test_build_trust_boundary_lookup_url_default_email( @mock.patch( "google.auth.compute_engine._metadata.get_universe_domain", autospec=True ) - def test_build_trust_boundary_lookup_url_explicit_email( + def test_build_regional_access_boundary_lookup_url_explicit_email( self, mock_get_universe_domain, mock_get_service_account_info ): # Test with an explicit service account email, no resolution needed @@ -593,58 +383,34 @@ def test_build_trust_boundary_lookup_url_explicit_email( creds._service_account_email = FAKE_SERVICE_ACCOUNT_EMAIL mock_get_universe_domain.return_value = "googleapis.com" - url = creds._build_trust_boundary_lookup_url() + url = creds._build_regional_access_boundary_lookup_url() mock_get_service_account_info.assert_not_called() - mock_get_universe_domain.assert_called_once_with(mock.ANY) assert url == ( "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo@bar.com/allowedLocations" ) - @mock.patch( - "google.auth.compute_engine._metadata.get_service_account_info", autospec=True - ) @mock.patch( "google.auth.compute_engine._metadata.get_universe_domain", autospec=True ) - def test_build_trust_boundary_lookup_url_non_default_universe( - self, mock_get_universe_domain, mock_get_service_account_info - ): - # Test with a non-default universe domain - creds = self.credentials_with_all_fields - - url = creds._build_trust_boundary_lookup_url() - - # Universe domain is cached and email is explicit, so no metadata calls needed. - mock_get_service_account_info.assert_not_called() - mock_get_universe_domain.assert_not_called() - assert url == ( - "https://iamcredentials.fake-universe-domain/v1/projects/-/serviceAccounts/foo@bar.com/allowedLocations" - ) - @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True ) - def test_build_trust_boundary_lookup_url_get_service_account_info_fails( - self, mock_get_service_account_info + def test_build_regional_access_boundary_lookup_url_get_service_account_info_fails( + self, mock_get_service_account_info, mock_get_universe_domain ): - # Test scenario where get_service_account_info fails mock_get_service_account_info.side_effect = exceptions.TransportError( - "Failed to get info" + "Metadata server error" ) creds = self.credentials - creds._service_account_email = "default" - - with pytest.raises( - exceptions.RefreshError, - match=r"Failed to get service account email for trust boundary lookup: .*", - ): - creds._build_trust_boundary_lookup_url() + url = creds._build_regional_access_boundary_lookup_url() + assert url is None + mock_get_service_account_info.assert_called_once() @mock.patch( "google.auth.compute_engine._metadata.get_service_account_info", autospec=True ) - def test_build_trust_boundary_lookup_url_no_email( + def test_build_regional_access_boundary_lookup_url_no_email( self, mock_get_service_account_info ): # Test with default service account email, which needs resolution, but metadata @@ -653,10 +419,8 @@ def test_build_trust_boundary_lookup_url_no_email( creds._service_account_email = "default" mock_get_service_account_info.return_value = {"scopes": ["one", "two"]} - with pytest.raises(exceptions.RefreshError) as excinfo: - creds._build_trust_boundary_lookup_url() - - assert excinfo.match(r"missing 'email' field") + url = creds._build_regional_access_boundary_lookup_url() + assert url is None @mock.patch("google.auth.compute_engine._metadata.get") @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py index 6a01b1bac..9d71a83fb 100644 --- a/tests/oauth2/test__client.py +++ b/tests/oauth2/test__client.py @@ -632,7 +632,7 @@ def test__token_endpoint_request_no_throw_with_retry(can_retry): assert mock_request.call_count == 1 -def test_lookup_trust_boundary(): +def test_lookup_regional_access_boundary(): response_data = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0x80080000000000", @@ -647,7 +647,9 @@ def test_lookup_trust_boundary(): url = "http://example.com" headers = {"Authorization": "Bearer access_token"} - response = _client._lookup_trust_boundary(mock_request, url, headers=headers) + response = _client._lookup_regional_access_boundary( + mock_request, url, headers=headers + ) assert response["encodedLocations"] == "0x80080000000000" assert response["locations"] == ["us-central1", "us-east1"] @@ -655,47 +657,7 @@ def test_lookup_trust_boundary(): mock_request.assert_called_once_with(method="GET", url=url, headers=headers) -def test_lookup_trust_boundary_no_op_response_without_locations(): - response_data = {"encodedLocations": "0x0"} - - mock_response = mock.create_autospec(transport.Response, instance=True) - mock_response.status = http_client.OK - mock_response.data = json.dumps(response_data).encode("utf-8") - - mock_request = mock.create_autospec(transport.Request) - mock_request.return_value = mock_response - - url = "http://example.com" - headers = {"Authorization": "Bearer access_token"} - # for the response to be valid, we only need encodedLocations to be present. - response = _client._lookup_trust_boundary(mock_request, url, headers=headers) - assert response["encodedLocations"] == "0x0" - assert "locations" not in response - - mock_request.assert_called_once_with(method="GET", url=url, headers=headers) - - -def test_lookup_trust_boundary_no_op_response(): - response_data = {"locations": [], "encodedLocations": "0x0"} - - mock_response = mock.create_autospec(transport.Response, instance=True) - mock_response.status = http_client.OK - mock_response.data = json.dumps(response_data).encode("utf-8") - - mock_request = mock.create_autospec(transport.Request) - mock_request.return_value = mock_response - - url = "http://example.com" - headers = {"Authorization": "Bearer access_token"} - response = _client._lookup_trust_boundary(mock_request, url, headers=headers) - - assert response["encodedLocations"] == "0x0" - assert response["locations"] == [] - - mock_request.assert_called_once_with(method="GET", url=url, headers=headers) - - -def test_lookup_trust_boundary_error(): +def test_lookup_regional_access_boundary_error(): mock_response = mock.create_autospec(transport.Response, instance=True) mock_response.status = http_client.INTERNAL_SERVER_ERROR mock_response.data = "this is an error message" @@ -705,33 +667,39 @@ def test_lookup_trust_boundary_error(): url = "http://example.com" headers = {"Authorization": "Bearer access_token"} - with pytest.raises(exceptions.RefreshError) as excinfo: - _client._lookup_trust_boundary(mock_request, url, headers=headers) - assert excinfo.match("this is an error message") + result = _client._lookup_regional_access_boundary( + mock_request, url, headers=headers + ) + assert result is None mock_request.assert_called_with(method="GET", url=url, headers=headers) -def test_lookup_trust_boundary_missing_encoded_locations(): - response_data = {"locations": [], "bad_field": "0x0"} - +@pytest.mark.parametrize( + "status_code", + [ + http_client.NOT_FOUND, + http_client.FORBIDDEN, + ], +) +def test_lookup_regional_access_boundary_non_retryable_error(status_code): mock_response = mock.create_autospec(transport.Response, instance=True) - mock_response.status = http_client.OK - mock_response.data = json.dumps(response_data).encode("utf-8") + mock_response.status = status_code + mock_response.data = "Error" mock_request = mock.create_autospec(transport.Request) mock_request.return_value = mock_response url = "http://example.com" headers = {"Authorization": "Bearer access_token"} - with pytest.raises(exceptions.MalformedError) as excinfo: - _client._lookup_trust_boundary(mock_request, url, headers=headers) - assert excinfo.match("Invalid trust boundary info") - + result = _client._lookup_regional_access_boundary( + mock_request, url, headers=headers + ) + assert result is None + # Non-retryable errors should only be called once. mock_request.assert_called_once_with(method="GET", url=url, headers=headers) - -def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): +def test_lookup_regional_access_boundary_internal_failure_and_retry_failure_error(): retryable_error = mock.create_autospec(transport.Response, instance=True) retryable_error.status = http_client.BAD_REQUEST retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( @@ -749,10 +717,10 @@ def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): request.side_effect = [retryable_error, retryable_error, unretryable_error] headers = {"Authorization": "Bearer access_token"} - with pytest.raises(exceptions.RefreshError): - _client._lookup_trust_boundary_request( - request, "http://example.com", headers=headers - ) + result = _client._lookup_regional_access_boundary_request( + request, "http://example.com", headers=headers + ) + assert result is None # request should be called three times. Two retryable errors and one # unretryable error to break the retry loop. assert request.call_count == 3 @@ -760,14 +728,17 @@ def test_lookup_trust_boundary_internal_failure_and_retry_failure_error(): assert call[1]["headers"] == headers -def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): +def test_lookup_regional_access_boundary_internal_failure_and_retry_succeeds(): retryable_error = mock.create_autospec(transport.Response, instance=True) retryable_error.status = http_client.BAD_REQUEST retryable_error.data = json.dumps({"error_description": "internal_failure"}).encode( "utf-8" ) - response_data = {"locations": [], "encodedLocations": "0x0"} + response_data = { + "locations": ["us-central1", "us-east1"], + "encodedLocations": "0xVALIDHEX", + } response = mock.create_autospec(transport.Response, instance=True) response.status = http_client.OK response.data = json.dumps(response_data).encode("utf-8") @@ -777,7 +748,7 @@ def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): headers = {"Authorization": "Bearer access_token"} request.side_effect = [retryable_error, response] - _ = _client._lookup_trust_boundary_request( + _ = _client._lookup_regional_access_boundary_request( request, "http://example.com", headers=headers ) @@ -786,7 +757,7 @@ def test_lookup_trust_boundary_internal_failure_and_retry_succeeds(): assert call[1]["headers"] == headers -def test_lookup_trust_boundary_with_headers(): +def test_lookup_regional_access_boundary_with_headers(): response_data = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0x80080000000000", @@ -800,7 +771,9 @@ def test_lookup_trust_boundary_with_headers(): mock_request.return_value = mock_response headers = {"Authorization": "Bearer access_token", "x-test-header": "test-value"} - _client._lookup_trust_boundary(mock_request, "http://example.com", headers=headers) + _client._lookup_regional_access_boundary( + mock_request, "http://example.com", headers=headers + ) mock_request.assert_called_once_with( method="GET", url="http://example.com", headers=headers diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 788f52263..414b9aee5 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -20,9 +20,7 @@ import pytest # type: ignore from google.auth import _helpers -from google.auth import credentials from google.auth import crypt -from google.auth import environment_vars from google.auth import exceptions from google.auth import iam from google.auth import jwt @@ -60,10 +58,6 @@ class TestCredentials(object): SERVICE_ACCOUNT_EMAIL = "service-account@example.com" TOKEN_URI = "https://example.com/oauth2/token" - NO_OP_TRUST_BOUNDARY = { - "locations": credentials.NO_OP_TRUST_BOUNDARY_LOCATIONS, - "encodedLocations": credentials.NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS, - } VALID_TRUST_BOUNDARY = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEXSA", @@ -77,14 +71,12 @@ class TestCredentials(object): def make_credentials( cls, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, # Align with Credentials class default ): return service_account.Credentials( SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI, universe_domain=universe_domain, - trust_boundary=trust_boundary, ) def test_get_cred_info(self): @@ -236,6 +228,24 @@ def test_with_quota_project(self): new_credentials.apply(hdrs, token="tok") assert "x-goog-user-project" in hdrs + def test_with_regional_access_boundary(self): + credentials = self.make_credentials() + new_credentials = credentials._with_regional_access_boundary( + {"encodedLocations": "new_boundary"} + ) + assert new_credentials is not credentials + assert new_credentials._regional_access_boundary == { + "encodedLocations": "new_boundary" + } + + def test_build_regional_access_boundary_lookup_url(self): + credentials = self.make_credentials() + expected_url = ( + "https://iamcredentials.googleapis.com/v1/projects/-/" + "serviceAccounts/{}/allowedLocations".format(credentials.service_account_email) + ) + assert credentials._build_regional_access_boundary_lookup_url() == expected_url + def test_with_token_uri(self): credentials = self.make_credentials() new_token_uri = "https://example2.com/oauth2/token" @@ -270,18 +280,6 @@ def test__with_always_use_jwt_access_non_default_universe_domain(self): "always_use_jwt_access should be True for non-default universe domain" ) - def test_with_trust_boundary(self): - credentials = self.make_credentials() - new_boundary = {"encodedLocations": "new_boundary"} - new_credentials = credentials.with_trust_boundary(new_boundary) - - assert new_credentials is not credentials - assert new_credentials._trust_boundary == new_boundary - assert new_credentials._signer == credentials._signer - assert ( - new_credentials.service_account_email == credentials.service_account_email - ) - def test__make_authorization_grant_assertion(self): credentials = self.make_credentials() token = credentials._make_authorization_grant_assertion() @@ -533,35 +531,6 @@ def test_refresh_success(self, jwt_grant): # boundary was provided. assert credentials._trust_boundary is None - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_skips_trust_boundary_lookup_non_default_universe( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Create credentials with a non-default universe domain - credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - # Ensure jwt_grant was called (token refresh happened) - mock_jwt_grant.assert_called_once() - # Ensure trust boundary lookup was not called - mock_lookup_trust_boundary.assert_not_called() - # Verify that x-allowed-locations header is not set by apply() - headers_applied = {} - credentials.apply(headers_applied) - assert "x-allowed-locations" not in headers_applied - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) def test_before_request_refreshes(self, jwt_grant): credentials = self.make_credentials() @@ -670,208 +639,6 @@ def test_refresh_non_gdu_domain_wide_delegation_not_supported(self): credentials.refresh(None) assert excinfo.match("domain wide delegation is not supported") - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_success_with_valid_trust_boundary( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Start with no boundary. - credentials = self.make_credentials(trust_boundary=None) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - # Mock the trust boundary lookup to return a valid boundary. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - - # Verify trust boundary was set. - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - - # Verify the mock was called with the correct URL. - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - - # Verify x-allowed-locations header is set correctly by apply(). - headers_applied = {} - credentials.apply(headers_applied) - assert ( - headers_applied["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_fetches_no_op_trust_boundary( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Start with no trust boundary - credentials = self.make_credentials(trust_boundary=None) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - mock_lookup_trust_boundary.return_value = self.NO_OP_TRUST_BOUNDARY - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - credentials = self.make_credentials( - trust_boundary=self.NO_OP_TRUST_BOUNDARY - ) # Start with NO_OP - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - # Verify trust boundary remained NO_OP - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - - # Lookup should be skipped - mock_lookup_trust_boundary.assert_not_called() - - # Verify that an empty header was added. - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_trust_boundary_lookup_fails_no_cache( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Start with no trust boundary - credentials = self.make_credentials(trust_boundary=None) - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - mock_jwt_grant.return_value = ( - "mock_access_token", - _helpers.utcnow() + datetime.timedelta(seconds=3600), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - # Mock the trust boundary lookup to raise an error - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), pytest.raises(exceptions.RefreshError, match="Lookup failed"): - credentials.refresh(request) - - assert credentials._trust_boundary is None - mock_lookup_trust_boundary.assert_called_once() - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) - def test_refresh_trust_boundary_lookup_fails_with_cached_data( - self, mock_jwt_grant, mock_lookup_trust_boundary - ): - # Initial setup: Credentials with no trust boundary. - credentials = self.make_credentials(trust_boundary=None) - token = "token" - mock_jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}, - ) - request = mock.create_autospec(transport.Request, instance=True) - - # First refresh: Successfully fetch a valid trust boundary. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == token - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - - # Second refresh: Mock lookup to fail, but expect cached data to be preserved. - mock_lookup_trust_boundary.reset_mock() - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) # This should NOT raise an exception - - assert credentials.valid # Credentials should still be valid - assert ( - credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - ) # Cached data should be preserved - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={ - "authorization": "Bearer token", - "x-allowed-locations": self.VALID_TRUST_BOUNDARY["encodedLocations"], - }, - ) # Lookup should have been attempted again - - def test_build_trust_boundary_lookup_url_no_email(self): - credentials = self.make_credentials() - credentials._service_account_email = None - - with pytest.raises(ValueError) as excinfo: - credentials._build_trust_boundary_lookup_url() - - assert "Service account email is required" in str(excinfo.value) - class TestIDTokenCredentials(object): SERVICE_ACCOUNT_EMAIL = "service-account@example.com" diff --git a/tests/test__helpers.py b/tests/test__helpers.py index 4d12abd86..a167902f6 100644 --- a/tests/test__helpers.py +++ b/tests/test__helpers.py @@ -20,7 +20,7 @@ import pytest # type: ignore -from google.auth import _helpers, exceptions +from google.auth import _helpers # _MOCK_BASE_LOGGER_NAME is the base logger namespace used for testing. _MOCK_BASE_LOGGER_NAME = "foogle" @@ -251,14 +251,11 @@ def test_get_bool_from_env(monkeypatch): # Test invalid value monkeypatch.setenv("TEST_VAR", "invalid_value") - with pytest.raises(exceptions.InvalidValue) as excinfo: - _helpers.get_bool_from_env("TEST_VAR") - assert 'must be one of "true", "false", "1", or "0"' in str(excinfo.value) + assert _helpers.get_bool_from_env("TEST_VAR") is False # Test empty string value monkeypatch.setenv("TEST_VAR", "") - with pytest.raises(exceptions.InvalidValue): - _helpers.get_bool_from_env("TEST_VAR") + assert _helpers.get_bool_from_env("TEST_VAR") is False def test_hash_sensitive_info_basic(): diff --git a/tests/test_aws.py b/tests/test_aws.py index b6b1ca231..477847525 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -973,7 +973,6 @@ def test_from_info_full_options(self, mock_init): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1003,7 +1002,6 @@ def test_from_info_required_options_only(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1035,7 +1033,6 @@ def test_from_info_supplier(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1073,7 +1070,6 @@ def test_from_file_full_options(self, mock_init, tmpdir): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -1104,7 +1100,6 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_constructor_invalid_credential_source(self): diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 0d64f0e0a..c7555bbc6 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -23,10 +23,14 @@ from google.auth import credentials from google.auth import environment_vars from google.auth import exceptions -from google.oauth2 import _client -class CredentialsImpl(credentials.CredentialsWithTrustBoundary): +class CredentialsImpl(credentials.CredentialsWithRegionalAccessBoundary): + def __init__(self, universe_domain=None): + super(CredentialsImpl, self).__init__() + if universe_domain: + self._universe_domain = universe_domain + def _perform_refresh_token(self, request): self.token = "refreshed-token" self.expiry = ( @@ -38,10 +42,15 @@ def _perform_refresh_token(self, request): def with_quota_project(self, quota_project_id): raise NotImplementedError() - def _build_trust_boundary_lookup_url(self): + def _build_regional_access_boundary_lookup_url(self): # Using self.token here to make the URL dynamic for testing purposes return "http://mock.url/lookup_for_{}".format(self.token) + def _make_copy(self): + new_credentials = self.__class__() + self._copy_regional_access_boundary_state(new_credentials) + return new_credentials + class CredentialsImplWithMetrics(credentials.Credentials): def refresh(self, request): @@ -119,10 +128,13 @@ def test_before_request(): assert "x-allowed-locations" not in headers -def test_before_request_with_trust_boundary(): +def test_before_request_with_regional_access_boundary(): DUMMY_BOUNDARY = "0xA30" credentials = CredentialsImpl() - credentials._trust_boundary = {"locations": [], "encodedLocations": DUMMY_BOUNDARY} + credentials._regional_access_boundary = { + "locations": [], + "encodedLocations": DUMMY_BOUNDARY, + } request = mock.Mock() headers = {} @@ -366,113 +378,237 @@ def test_token_state_no_expiry(): c.before_request(request, "http://example.com", "GET", {}) -class TestCredentialsWithTrustBoundary(object): - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_env_var_not_true(self, mock_lookup_tb): +class TestCredentialsWithRegionalAccessBoundary(object): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_env_var_not_set( + self, mock_start_refresh + ): creds = CredentialsImpl() - request = mock.Mock() + with mock.patch.dict(os.environ, clear=True): + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() - # Ensure env var is not "true" + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_not_expired(self, mock_start_refresh): + creds = CredentialsImpl() + creds._regional_access_boundary = {"encodedLocations": "test"} + creds._regional_access_boundary_expiry = _helpers.utcnow() + datetime.timedelta( + minutes=5 + ) with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "false"} + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, ): - result = creds._refresh_trust_boundary(request) + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() - assert result is None - mock_lookup_tb.assert_not_called() + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_cooldown_active( + self, mock_start_refresh + ): + creds = CredentialsImpl() + creds._regional_access_boundary_cooldown_expiry = ( + _helpers.utcnow() + datetime.timedelta(minutes=5) + ) + with mock.patch.dict( + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, + ): + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() + + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_for_regional_endpoint( + self, mock_start_refresh + ): + creds = CredentialsImpl() + with mock.patch.dict( + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, + ): + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "https://my-service.us-east1.rep.googleapis.com" + ) + mock_start_refresh.assert_not_called() - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_env_var_missing(self, mock_lookup_tb): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_triggered(self, mock_start_refresh): creds = CredentialsImpl() request = mock.Mock() + with mock.patch.dict( + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, + ): + creds._maybe_start_regional_access_boundary_refresh( + request, "http://example.com" + ) + mock_start_refresh.assert_called_once_with(creds, request) - # Ensure env var is missing - with mock.patch.dict(os.environ, clear=True): - result = creds._refresh_trust_boundary(request) + def test_get_regional_access_boundary_header(self): + creds = CredentialsImpl() + creds._regional_access_boundary = {"encodedLocations": "0xABC"} + headers = creds._get_regional_access_boundary_header() + assert headers == {"x-allowed-locations": "0xABC"} + + creds._regional_access_boundary = None + headers = creds._get_regional_access_boundary_header() + assert headers == {} + + def test_copy_regional_access_boundary_state(self): + source_creds = CredentialsImpl() + source_creds._regional_access_boundary = {"encodedLocations": "0xABC"} + source_creds._regional_access_boundary_expiry = _helpers.utcnow() + source_creds._regional_access_boundary_cooldown_expiry = _helpers.utcnow() + + target_creds = CredentialsImpl() + source_creds._copy_regional_access_boundary_state(target_creds) + + assert ( + target_creds._regional_access_boundary + == source_creds._regional_access_boundary + ) + assert ( + target_creds._regional_access_boundary_expiry + == source_creds._regional_access_boundary_expiry + ) + assert ( + target_creds._regional_access_boundary_cooldown_expiry + == source_creds._regional_access_boundary_cooldown_expiry + ) - assert result is None - mock_lookup_tb.assert_not_called() + def test_with_regional_access_boundary_valid_input(self): + creds = CredentialsImpl() + rab_info = {"encodedLocations": "new_location"} + new_creds = creds._with_regional_access_boundary(rab_info) - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_non_default_universe(self, mock_lookup_tb): + assert new_creds._regional_access_boundary == rab_info + assert new_creds._regional_access_boundary_expiry is not None + assert new_creds._regional_access_boundary_cooldown_expiry is None + + def test_with_regional_access_boundary_malformed_input(self): creds = CredentialsImpl() - creds._universe_domain = "my.universe.com" # Non-GDU - request = mock.Mock() + with pytest.raises( + exceptions.InvalidValue, + match="regional_access_boundary must be a dictionary with an 'encodedLocations' key.", + ): + creds._with_regional_access_boundary({"bad_key": "bad_value"}) + with pytest.raises( + exceptions.InvalidValue, + match="regional_access_boundary must be a dictionary with an 'encodedLocations' key.", + ): + creds._with_regional_access_boundary("not_a_dict") + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_is_skipped_if_non_default_universe_domain( + self, mock_start_refresh + ): + creds = CredentialsImpl(universe_domain="not.googleapis.com") with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, ): - result = creds._refresh_trust_boundary(request) + creds._maybe_start_regional_access_boundary_refresh( + mock.Mock(), "http://example.com" + ) + mock_start_refresh.assert_not_called() - assert result is None - mock_lookup_tb.assert_not_called() - - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_calls_client_and_build_url(self, mock_lookup_tb): + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + @mock.patch("urllib.parse.urlparse") + def test_maybe_start_refresh_handles_url_parse_errors( + self, mock_urlparse, mock_start_refresh + ): + mock_urlparse.side_effect = ValueError("Malformed URL") creds = CredentialsImpl() - creds.token = "test_token" # For _build_trust_boundary_lookup_url request = mock.Mock() - expected_url = "http://mock.url/lookup_for_test_token" - expected_boundary_info = {"encodedLocations": "0xABC"} - mock_lookup_tb.return_value = expected_boundary_info - - # Mock _build_trust_boundary_lookup_url to ensure it's called. - mock_build_url = mock.Mock(return_value=expected_url) - creds._build_trust_boundary_lookup_url = mock_build_url - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, ): - result = creds._lookup_trust_boundary(request) + creds._maybe_start_regional_access_boundary_refresh( + request, "http://malformed-url" + ) + mock_start_refresh.assert_called_once_with(creds, request) + + @mock.patch("google.oauth2._client._lookup_regional_access_boundary") + @mock.patch.object(CredentialsImpl, "_build_regional_access_boundary_lookup_url") + def test_lookup_regional_access_boundary_success( + self, mock_build_url, mock_lookup_rab + ): + creds = CredentialsImpl() + creds.token = "token" + request = mock.Mock() + mock_build_url.return_value = "http://rab.example.com" + mock_lookup_rab.return_value = {"encodedLocations": "success"} + + result = creds._lookup_regional_access_boundary(request) - assert result == expected_boundary_info mock_build_url.assert_called_once() - expected_headers = {"authorization": "Bearer test_token"} - mock_lookup_tb.assert_called_once_with( - request, expected_url, headers=expected_headers + mock_lookup_rab.assert_called_once_with( + request, "http://rab.example.com", headers={"authorization": "Bearer token"} ) + assert result == {"encodedLocations": "success"} - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_lookup_trust_boundary_build_url_returns_none(self, mock_lookup_tb): + @mock.patch("google.oauth2._client._lookup_regional_access_boundary") + @mock.patch.object(CredentialsImpl, "_build_regional_access_boundary_lookup_url") + def test_lookup_regional_access_boundary_failure( + self, mock_build_url, mock_lookup_rab + ): creds = CredentialsImpl() + creds.token = "token" request = mock.Mock() + mock_build_url.return_value = "http://rab.example.com" + mock_lookup_rab.return_value = None - # Mock _build_trust_boundary_lookup_url to return None - mock_build_url = mock.Mock(return_value=None) - creds._build_trust_boundary_lookup_url = mock_build_url + result = creds._lookup_regional_access_boundary(request) - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - with pytest.raises( - exceptions.InvalidValue, - match="Failed to build trust boundary lookup URL.", - ): - creds._lookup_trust_boundary(request) - - mock_build_url.assert_called_once() # Ensure _build_trust_boundary_lookup_url was called - mock_lookup_tb.assert_not_called() # Ensure _client.lookup_trust_boundary was not called - - @mock.patch("google.auth.credentials._LOGGER") - @mock.patch("google.auth._helpers.is_logging_enabled", return_value=True) - @mock.patch.object(_client, "_lookup_trust_boundary") - def test_refresh_trust_boundary_fails_with_cached_data_and_logging( - self, mock_lookup_tb, mock_is_logging_enabled, mock_logger + mock_build_url.assert_called_once() + mock_lookup_rab.assert_called_once_with( + request, "http://rab.example.com", headers={"authorization": "Bearer token"} + ) + assert result is None + + @mock.patch("google.oauth2._client._lookup_regional_access_boundary") + @mock.patch.object(CredentialsImpl, "_build_regional_access_boundary_lookup_url") + def test_lookup_regional_access_boundary_null_url( + self, mock_build_url, mock_lookup_rab ): creds = CredentialsImpl() - creds._trust_boundary = {"encodedLocations": "0xABC"} + creds.token = "token" request = mock.Mock() + mock_build_url.return_value = None - refresh_error = exceptions.RefreshError("Lookup failed") - mock_lookup_tb.side_effect = refresh_error + result = creds._lookup_regional_access_boundary(request) - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - creds.refresh(request) + mock_build_url.assert_called_once() + mock_lookup_rab.assert_not_called() + assert result is None - mock_lookup_tb.assert_called_once() - mock_is_logging_enabled.assert_called_once_with(mock_logger) - mock_logger.debug.assert_called_once_with( - "Using cached trust boundary due to refresh error: %s", refresh_error + def test_credentials_with_regional_access_boundary_initialization(self): + creds = CredentialsImpl() + assert creds._regional_access_boundary is None + assert creds._regional_access_boundary_expiry is None + assert creds._regional_access_boundary_cooldown_expiry is None + assert creds._current_rab_cooldown_duration == ( + credentials._regional_access_boundary_utils.DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN ) + assert creds._stale_boundary_lock is not None diff --git a/tests/test_external_account.py b/tests/test_external_account.py index e50a1b8c0..3aaef7834 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -15,14 +15,12 @@ import datetime import http.client as http_client import json -import os from unittest import mock import urllib import pytest # type: ignore from google.auth import _helpers -from google.auth import environment_vars from google.auth import exceptions from google.auth import external_account from google.auth import transport @@ -128,7 +126,6 @@ class TestCredentials(object): "status": "INVALID_ARGUMENT", } } - NO_OP_TRUST_BOUNDARY = {"locations": [], "encodedLocations": "0x0"} VALID_TRUST_BOUNDARY = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEXSA", @@ -158,7 +155,6 @@ def make_credentials( service_account_impersonation_url=None, service_account_impersonation_options={}, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ): return CredentialsImpl( audience=cls.AUDIENCE, @@ -174,7 +170,6 @@ def make_credentials( scopes=scopes, default_scopes=default_scopes, universe_domain=universe_domain, - trust_boundary=trust_boundary, ) @classmethod @@ -187,7 +182,6 @@ def make_workforce_pool_credentials( default_scopes=None, service_account_impersonation_url=None, workforce_pool_user_project=None, - trust_boundary=None, ): return CredentialsImpl( audience=cls.WORKFORCE_AUDIENCE, @@ -201,7 +195,6 @@ def make_workforce_pool_credentials( scopes=scopes, default_scopes=default_scopes, workforce_pool_user_project=workforce_pool_user_project, - trust_boundary=trust_boundary, ) @classmethod @@ -435,7 +428,6 @@ def test_with_scopes_full_options_propagated(self): scopes=["email"], default_scopes=["default2"], universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_with_token_uri(self): @@ -524,7 +516,6 @@ def test_with_quota_project_full_options_propagated(self): scopes=self.SCOPES, default_scopes=["default1"], universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) # Confirm with_quota_project sets the correct quota project after @@ -719,24 +710,6 @@ def test_refresh_without_client_auth_success( assert not credentials.expired assert credentials.token == response["access_token"] - @mock.patch("google.auth.external_account.Credentials._lookup_trust_boundary") - def test_refresh_skips_trust_boundary_lookup_when_disabled( - self, mock_lookup_trust_boundary - ): - credentials = self.make_credentials() - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - - credentials.refresh(request) - - assert credentials.valid - assert credentials.token == self.SUCCESS_RESPONSE["access_token"] - mock_lookup_trust_boundary.assert_not_called() - headers_applied = {} - credentials.apply(headers_applied) - assert "x-allowed-locations" not in headers_applied - def test_perform_refresh_token_with_cert_fingerprint(self): credentials = self.make_credentials() credentials._sts_client = mock.MagicMock() @@ -755,240 +728,6 @@ def test_perform_refresh_token_with_cert_fingerprint(self): _, kwargs = credentials._sts_client.exchange_token.call_args assert kwargs["additional_options"]["bindCertFingerprint"] == "my-fingerprint" - def test_refresh_skips_sending_allowed_locations_header_with_trust_boundary(self): - # This test verifies that the x-allowed-locations header is not sent with - # the STS request even if a trust boundary is cached. - trust_boundary_value = {"encodedLocations": "0x12345"} - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", - } - request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": self.AUDIENCE, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "subject_token": "subject_token_0", - "subject_token_type": self.SUBJECT_TOKEN_TYPE, - } - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - # Set a cached trust boundary. - credentials._trust_boundary = trust_boundary_value - - with mock.patch( - "google.auth.metrics.python_and_auth_lib_version", - return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - self.assert_token_request_kwargs(request.call_args[1], headers, request_data) - - def test_refresh_on_impersonated_credential_skips_parent_trust_boundary_lookup( - self, - ): - # This test verifies that the top-level impersonating credential - # does not perform a trust boundary lookup. - request = self.make_mock_request( - status=http_client.OK, - data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data={ - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": "2025-01-01T00:00:00Z", - }, - ) - credentials = self.make_credentials( - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL - ) - - with mock.patch.object( - credentials, "_refresh_trust_boundary", autospec=True - ) as mock_refresh_trust_boundary: - credentials.refresh(request) - - mock_refresh_trust_boundary.assert_not_called() - - def test_refresh_fetches_no_op_trust_boundary(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value=self.NO_OP_TRUST_BOUNDARY, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "" - - def test_refresh_skips_lookup_with_cached_no_op_boundary(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - credentials._trust_boundary = self.NO_OP_TRUST_BOUNDARY - - with mock.patch.object( - credentials, "_lookup_trust_boundary" - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_not_called() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "" - - def test_refresh_fails_on_lookup_failure_with_no_cache(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - side_effect=exceptions.RefreshError("Lookup failed"), - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), pytest.raises( - exceptions.RefreshError, match="Lookup failed" - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - - def test_refresh_uses_cached_boundary_on_lookup_failure(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - credentials._trust_boundary = {"encodedLocations": "0x123"} - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - side_effect=exceptions.RefreshError("Lookup failed"), - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "0x123" - - def test_refresh_propagates_trust_boundary_to_impersonated_credential(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials( - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, - trust_boundary=self.VALID_TRUST_BOUNDARY, - ) - impersonated_creds_mock = mock.Mock() - impersonated_creds_mock._trust_boundary = self.VALID_TRUST_BOUNDARY - - with mock.patch( - "google.auth.external_account.impersonated_credentials.Credentials", - return_value=impersonated_creds_mock, - ) as mock_impersonated_creds, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_impersonated_creds.assert_called_once_with( - source_credentials=mock.ANY, - target_principal=mock.ANY, - target_scopes=mock.ANY, - quota_project_id=mock.ANY, - iam_endpoint_override=mock.ANY, - lifetime=mock.ANY, - trust_boundary=self.VALID_TRUST_BOUNDARY, - ) - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - - def test_build_trust_boundary_lookup_url_workload(self): - credentials = self.make_credentials() - expected_url = "https://iamcredentials.googleapis.com/v1/projects/123456/locations/global/workloadIdentityPools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url - - def test_build_trust_boundary_lookup_url_workforce(self): - credentials = self.make_workforce_pool_credentials() - expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url - - @pytest.mark.parametrize( - "audience", - [ - "invalid", - "//iam.googleapis.com/projects/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", - "//iam.googleapis.com/locations/global/workforcsePools//providers/provider-id", - ], - ) - def test_build_trust_boundary_lookup_url_invalid_audience(self, audience): - credentials = self.make_credentials() - credentials._audience = audience - with pytest.raises(exceptions.InvalidValue, match="Invalid audience format."): - credentials._build_trust_boundary_lookup_url() - - def test_refresh_fetches_trust_boundary_workload(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value=self.VALID_TRUST_BOUNDARY, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert ( - headers["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - - def test_refresh_fetches_trust_boundary_workforce(self): - request = self.make_mock_request( - status=http_client.OK, data=self.SUCCESS_RESPONSE - ) - credentials = self.make_workforce_pool_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value=self.VALID_TRUST_BOUNDARY, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert ( - headers["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - @mock.patch( "google.auth.metrics.python_and_auth_lib_version", return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, @@ -1990,34 +1729,38 @@ def test_before_request_expired(self, utcnow): "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) } - def test_refresh_impersonation_trust_boundary(self): - request = self.make_mock_request( - status=http_client.OK, - data=self.SUCCESS_RESPONSE, - impersonation_status=http_client.OK, - impersonation_data={ - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": "2025-01-01T00:00:00Z", - }, - ) - credentials = self.make_credentials( - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL - ) - impersonated_creds_mock = mock.Mock() - impersonated_creds_mock._trust_boundary = self.VALID_TRUST_BOUNDARY + def test_build_regional_access_boundary_lookup_url_workload(self): + credentials = self.make_credentials() + expected_url = "https://iamcredentials.googleapis.com/v1/projects/123456/locations/global/workloadIdentityPools/POOL_ID/allowedLocations" + assert credentials._build_regional_access_boundary_lookup_url() == expected_url - with mock.patch( - "google.auth.external_account.impersonated_credentials.Credentials", - return_value=impersonated_creds_mock, - ): - credentials.refresh(request) + def test_build_regional_access_boundary_lookup_url_workforce(self): + credentials = self.make_workforce_pool_credentials() + expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" + assert credentials._build_regional_access_boundary_lookup_url() == expected_url - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + @pytest.mark.parametrize( + "audience", + [ + "invalid", + "//iam.googleapis.com/projects/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + "//iam.googleapis.com/locations/global/workforcsePools//providers/provider-id", + ], + ) + def test_build_regional_access_boundary_lookup_url_invalid_audience(self, audience): + credentials = self.make_credentials() + credentials._audience = audience + with pytest.raises(exceptions.InvalidValue, match="Invalid audience format."): + credentials._build_regional_access_boundary_lookup_url() - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): credentials = self.make_credentials() - new_credentials = credentials.with_trust_boundary(self.VALID_TRUST_BOUNDARY) - assert new_credentials._trust_boundary == self.VALID_TRUST_BOUNDARY + new_credentials = credentials._with_regional_access_boundary( + {"encodedLocations": "new_boundary"} + ) + assert new_credentials._regional_access_boundary == { + "encodedLocations": "new_boundary" + } @mock.patch("google.auth._helpers.utcnow") def test_before_request_impersonation_expired(self, utcnow): diff --git a/tests/test_external_account_authorized_user.py b/tests/test_external_account_authorized_user.py index b023ddf3d..428a1f8f8 100644 --- a/tests/test_external_account_authorized_user.py +++ b/tests/test_external_account_authorized_user.py @@ -15,12 +15,10 @@ import datetime import http.client as http_client import json -import os from unittest import mock import pytest # type: ignore -from google.auth import environment_vars from google.auth import exceptions from google.auth import external_account_authorized_user from google.auth import transport @@ -559,26 +557,6 @@ def test_with_universe_domain(self): assert new_creds._quota_project_id == QUOTA_PROJECT_ID assert new_creds.universe_domain == FAKE_UNIVERSE_DOMAIN - def test_with_trust_boundary(self): - creds = self.make_credentials( - token=ACCESS_TOKEN, - expiry=NOW, - revoke_url=REVOKE_URL, - quota_project_id=QUOTA_PROJECT_ID, - ) - new_creds = creds.with_trust_boundary({"encodedLocations": "new_boundary"}) - assert new_creds._audience == creds._audience - assert new_creds._refresh_token == creds.refresh_token - assert new_creds._token_url == creds._token_url - assert new_creds._token_info_url == creds._token_info_url - assert new_creds._client_id == creds._client_id - assert new_creds._client_secret == creds._client_secret - assert new_creds.token == creds.token - assert new_creds.expiry == creds.expiry - assert new_creds._revoke_url == creds._revoke_url - assert new_creds._quota_project_id == QUOTA_PROJECT_ID - assert new_creds._trust_boundary == {"encodedLocations": "new_boundary"} - def test_from_file_required_options_only(self, tmpdir): from_creds = self.make_credentials() config_file = tmpdir.join("config.json") @@ -623,64 +601,33 @@ def test_from_file_full_options(self, tmpdir): assert creds._revoke_url == REVOKE_URL assert creds._quota_project_id == QUOTA_PROJECT_ID - def test_refresh_fetches_trust_boundary(self): - request = self.make_mock_request( - status=http_client.OK, - data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, + def test_with_regional_access_boundary(self): + creds = self.make_credentials( + token=ACCESS_TOKEN, + expiry=NOW, + revoke_url=REVOKE_URL, + quota_project_id=QUOTA_PROJECT_ID, ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, - "_lookup_trust_boundary", - return_value={"encodedLocations": "0x123"}, - ) as mock_lookup, mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - mock_lookup.assert_called_once() - headers = {} - credentials.apply(headers) - assert headers["x-allowed-locations"] == "0x123" - - def test_refresh_skips_trust_boundary_lookup_when_disabled(self): - request = self.make_mock_request( - status=http_client.OK, - data={"access_token": ACCESS_TOKEN, "expires_in": 3600}, + new_creds = creds._with_regional_access_boundary( + {"encodedLocations": "new_boundary"} ) - credentials = self.make_credentials() - - with mock.patch.object( - credentials, "_lookup_trust_boundary" - ) as mock_lookup, mock.patch.dict(os.environ, {}, clear=True): - credentials.refresh(request) - - mock_lookup.assert_not_called() - headers = {} - credentials.apply(headers) - assert "x-allowed-locations" not in headers + assert new_creds is not creds + assert new_creds._regional_access_boundary == { + "encodedLocations": "new_boundary" + } - def test_build_trust_boundary_lookup_url(self): + def test_build_regional_access_boundary_lookup_url(self): credentials = self.make_credentials() - expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url + expected_url = "https://iamcredentials.googleapis.com/v1/workforcePools/POOL_ID/locations/global/allowedLocations" + assert credentials._build_regional_access_boundary_lookup_url() == expected_url @pytest.mark.parametrize( "audience", [ "invalid", - "//iam.googleapis.com/locations/global/workforcePools/", "//iam.googleapis.com/locations/global/providers/", - "//iam.googleapis.com/workforcePools/POOL_ID/providers/PROVIDER_ID", ], ) - def test_build_trust_boundary_lookup_url_invalid_audience(self, audience): + def test_build_regional_access_boundary_lookup_url_invalid_audience(self, audience): credentials = self.make_credentials(audience=audience) - with pytest.raises(exceptions.InvalidValue): - credentials._build_trust_boundary_lookup_url() - - def test_build_trust_boundary_lookup_url_different_universe(self): - credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) - expected_url = "https://iamcredentials.fake-universe-domain/v1/locations/global/workforcePools/POOL_ID/allowedLocations" - assert credentials._build_trust_boundary_lookup_url() == expected_url + assert credentials._build_regional_access_boundary_lookup_url() is None diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index c68fac647..2ffae6b8d 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -508,7 +508,6 @@ def test_from_info_full_options(self, mock_init): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -538,7 +537,6 @@ def test_from_info_required_options_only(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -570,7 +568,6 @@ def test_from_info_supplier(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -601,7 +598,6 @@ def test_from_info_workforce_pool(self, mock_init): quota_project_id=None, workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -638,7 +634,6 @@ def test_from_file_full_options(self, mock_init, tmpdir): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -669,7 +664,6 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -701,7 +695,6 @@ def test_from_file_workforce_pool(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=WORKFORCE_POOL_USER_PROJECT, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_constructor_nonworkforce_with_workforce_pool_user_project(self): diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 3ff7281a6..07e63d0bb 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -22,7 +22,6 @@ import pytest # type: ignore from google.auth import _helpers -from google.auth import credentials as auth_credentials from google.auth import crypt from google.auth import environment_vars from google.auth import exceptions @@ -128,10 +127,6 @@ class TestImpersonatedCredentials(object): # Because Python 2.7: DELEGATES = [] # type: ignore LIFETIME = 3600 - NO_OP_TRUST_BOUNDARY = { - "locations": auth_credentials.NO_OP_TRUST_BOUNDARY_LOCATIONS, - "encodedLocations": auth_credentials.NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS, - } VALID_TRUST_BOUNDARY = { "locations": ["us-central1", "us-east1"], "encodedLocations": "0xVALIDHEX", @@ -142,7 +137,7 @@ class TestImpersonatedCredentials(object): ) FAKE_UNIVERSE_DOMAIN = "universe.foo" SOURCE_CREDENTIALS = service_account.Credentials( - SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI, trust_boundary=NO_OP_TRUST_BOUNDARY + SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI ) USER_SOURCE_CREDENTIALS = credentials.Credentials(token="ABCDE") IAM_ENDPOINT_OVERRIDE = ( @@ -157,7 +152,6 @@ def make_credentials( target_principal=TARGET_PRINCIPAL, subject=None, iam_endpoint_override=None, - trust_boundary=None, # Align with Credentials class default ): return Credentials( source_credentials=source_credentials, @@ -167,7 +161,6 @@ def make_credentials( lifetime=lifetime, subject=subject, iam_endpoint_override=iam_endpoint_override, - trust_boundary=trust_boundary, ) def test_from_impersonated_service_account_info(self): @@ -178,17 +171,6 @@ def test_from_impersonated_service_account_info(self): ) assert isinstance(credentials, impersonated_credentials.Credentials) - def test_from_impersonated_service_account_info_with_trust_boundary(self): - info = copy.deepcopy(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_INFO) - info["trust_boundary"] = self.VALID_TRUST_BOUNDARY - credentials = ( - impersonated_credentials.Credentials.from_impersonated_service_account_info( - info - ) - ) - assert isinstance(credentials, impersonated_credentials.Credentials) - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - def test_from_impersonated_service_account_info_with_invalid_source_credentials_type( self, ): @@ -311,12 +293,9 @@ def test_token_usage_metrics(self): assert headers["x-goog-api-client"] == "cred-type/imp" @pytest.mark.parametrize("use_data_bytes", [True, False]) - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_success( - self, mock_lookup_trust_boundary, use_data_bytes, mock_donor_credentials - ): + def test_refresh_success(self, use_data_bytes, mock_donor_credentials): # Start with no boundary. - credentials = self.make_credentials(lifetime=None, trust_boundary=None) + credentials = self.make_credentials(lifetime=None) token = "token" expire_time = ( @@ -330,9 +309,6 @@ def test_refresh_success( use_data_bytes=use_data_bytes, ) - # Mock the trust boundary lookup to return a valid value. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - with mock.patch.dict( os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} ), mock.patch( @@ -348,32 +324,6 @@ def test_refresh_success( == ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE ) - # Verify that the x-allowed-locations header from the source credential - # was applied. The source credential has a NO_OP boundary, so the - # header should be an empty string. - request_kwargs = request.call_args[1] - assert "headers" in request_kwargs - assert "x-allowed-locations" in request_kwargs["headers"] - assert request_kwargs["headers"]["x-allowed-locations"] == "" - - # Verify trust boundary was set. - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - - # Verify the mock was called with the correct URL. - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - - # Verify x-allowed-locations header is set correctly by apply(). - headers_applied = {} - credentials.apply(headers_applied) - assert ( - headers_applied["x-allowed-locations"] - == self.VALID_TRUST_BOUNDARY["encodedLocations"] - ) - def test_refresh_source_creds_no_trust_boundary(self): # Use a source credential that does not support trust boundaries. source_credentials = credentials.Credentials(token="source_token") @@ -396,191 +346,6 @@ def test_refresh_source_creds_no_trust_boundary(self): request_kwargs = request.call_args[1] assert "x-allowed-locations" not in request_kwargs["headers"] - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_trust_boundary_lookup_fails_no_cache( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - # Start with no trust boundary - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - # Mock the trust boundary lookup to raise an error - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), pytest.raises(exceptions.RefreshError) as excinfo: - credentials.refresh(request) - - assert "Lookup failed" in str(excinfo.value) - assert credentials._trust_boundary is None # Still no trust boundary - mock_lookup_trust_boundary.assert_called_once() - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_fetches_no_op_trust_boundary( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - # Start with no trust boundary - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - mock_lookup_trust_boundary.return_value = ( - self.NO_OP_TRUST_BOUNDARY - ) # Mock returns NO_OP - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once_with( - request, - self.EXPECTED_TRUST_BOUNDARY_LOOKUP_URL_DEFAULT_UNIVERSE, - headers={"authorization": "Bearer token"}, - ) - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_skips_trust_boundary_lookup_non_default_universe( - self, mock_lookup_trust_boundary - ): - # Create source credentials with a non-default universe domain - source_credentials = service_account.Credentials( - SIGNER, - "some@email.com", - TOKEN_URI, - universe_domain=self.FAKE_UNIVERSE_DOMAIN, - ) - # Create impersonated credentials using the non-default source credentials - credentials = self.make_credentials(source_credentials=source_credentials) - - # Mock the IAM credentials API call for generateAccessToken - token = "token" - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - # Ensure trust boundary lookup was not called - mock_lookup_trust_boundary.assert_not_called() - # Verify that x-allowed-locations header is not set by apply() - headers_applied = {} - credentials.apply(headers_applied) - assert "x-allowed-locations" not in headers_applied - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_starts_with_no_op_trust_boundary_skips_lookup( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - credentials = self.make_credentials( - lifetime=None, trust_boundary=self.NO_OP_TRUST_BOUNDARY - ) # Start with NO_OP - token = "token" - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - # Verify trust boundary remained NO_OP - assert credentials._trust_boundary == self.NO_OP_TRUST_BOUNDARY - - # Lookup should be skipped - mock_lookup_trust_boundary.assert_not_called() - - # Verify that an empty header was added. - headers_applied = {} - credentials.apply(headers_applied) - assert headers_applied["x-allowed-locations"] == "" - - @mock.patch("google.oauth2._client._lookup_trust_boundary") - def test_refresh_trust_boundary_lookup_fails_with_cached_data2( - self, mock_lookup_trust_boundary, mock_donor_credentials - ): - # Start with no trust boundary - credentials = self.make_credentials(lifetime=None, trust_boundary=None) - token = "token" - - expire_time = ( - _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500) - ).isoformat("T") + "Z" - response_body = {"accessToken": token, "expireTime": expire_time} - - request = self.make_request( - data=json.dumps(response_body), status=http_client.OK - ) - - # First refresh: Successfully fetch a valid trust boundary. - mock_lookup_trust_boundary.return_value = self.VALID_TRUST_BOUNDARY - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ), mock.patch( - "google.auth.metrics.token_request_access_token_impersonate", - return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - ): - credentials.refresh(request) - - assert credentials.valid - # Verify trust boundary was set. - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once() - - # Second refresh: Mock lookup to fail, but expect cached data to be preserved. - mock_lookup_trust_boundary.reset_mock() - mock_lookup_trust_boundary.side_effect = exceptions.RefreshError( - "Lookup failed" - ) - - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"} - ): - credentials.refresh(request) - - assert credentials.valid - assert credentials._trust_boundary == self.VALID_TRUST_BOUNDARY - mock_lookup_trust_boundary.assert_called_once() - @pytest.mark.parametrize("use_data_bytes", [True, False]) def test_refresh_with_subject_success(self, use_data_bytes, mock_dwd_credentials): credentials = self.make_credentials(subject="test@email.com", lifetime=None) @@ -981,15 +746,14 @@ def test_with_scopes(self): assert credentials.requires_scopes is False assert credentials._target_scopes == ["fake_scope1", "fake_scope2"] - def test_with_trust_boundary(self): + def test_with_regional_access_boundary(self): credentials = self.make_credentials() new_boundary = {"encodedLocations": "new_boundary"} - new_credentials = credentials.with_trust_boundary(new_boundary) + new_credentials = credentials._with_regional_access_boundary(new_boundary) assert new_credentials is not credentials - assert new_credentials._trust_boundary == new_boundary + assert new_credentials._regional_access_boundary == new_boundary # The source credentials should be a copy, not the same object. - # But they should be functionally equivalent. assert ( new_credentials._source_credentials is not credentials._source_credentials ) @@ -1004,13 +768,18 @@ def test_with_trust_boundary(self): ) assert new_credentials._target_principal == credentials._target_principal - def test_build_trust_boundary_lookup_url_no_email(self): + def test_build_regional_access_boundary_lookup_url_no_email(self): credentials = self.make_credentials(target_principal=None) - with pytest.raises(ValueError) as excinfo: - credentials._build_trust_boundary_lookup_url() + assert credentials._build_regional_access_boundary_lookup_url() is None - assert "Service account email is required" in str(excinfo.value) + def test_build_regional_access_boundary_lookup_url_success(self): + credentials = self.make_credentials() + # Ensure service_account_email is properly set by default mock + expected_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}/allowedLocations".format( + credentials.service_account_email + ) + assert credentials._build_regional_access_boundary_lookup_url() == expected_url def test_with_scopes_provide_default_scopes(self): credentials = self.make_credentials() diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index b2764361b..70e8c188a 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -274,7 +274,6 @@ def test_from_info_full_options(self, mock_init): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -303,7 +302,6 @@ def test_from_info_required_options_only(self, mock_init): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -339,7 +337,6 @@ def test_from_file_full_options(self, mock_init, tmpdir): quota_project_id=QUOTA_PROJECT_ID, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -369,7 +366,6 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): quota_project_id=None, workforce_pool_user_project=None, universe_domain=DEFAULT_UNIVERSE_DOMAIN, - trust_boundary=None, ) def test_constructor_invalid_options(self): From 2d693588e66c48ddee433f5b3f0b79cee4c046db Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:46:51 -0800 Subject: [PATCH 06/11] test: Correct regional access boundary lookup URL path. --- tests/test_external_account_authorized_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_external_account_authorized_user.py b/tests/test_external_account_authorized_user.py index 428a1f8f8..e7ff6d951 100644 --- a/tests/test_external_account_authorized_user.py +++ b/tests/test_external_account_authorized_user.py @@ -618,7 +618,7 @@ def test_with_regional_access_boundary(self): def test_build_regional_access_boundary_lookup_url(self): credentials = self.make_credentials() - expected_url = "https://iamcredentials.googleapis.com/v1/workforcePools/POOL_ID/locations/global/allowedLocations" + expected_url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" assert credentials._build_regional_access_boundary_lookup_url() == expected_url @pytest.mark.parametrize( From 6458f5aa802257c7694c90a68d04a1066015a5f8 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:35:15 -0800 Subject: [PATCH 07/11] Fix minor issues --- google/auth/_regional_access_boundary_utils.py | 5 ++++- google/auth/compute_engine/credentials.py | 1 + google/auth/external_account.py | 7 ++++++- google/oauth2/_service_account_async.py | 10 +++++++++- tests/oauth2/test__client.py | 1 + tests/oauth2/test_service_account.py | 4 +++- tests/test_external_account.py | 13 +++++++++++-- 7 files changed, 35 insertions(+), 6 deletions(-) diff --git a/google/auth/_regional_access_boundary_utils.py b/google/auth/_regional_access_boundary_utils.py index 95c154bc8..b31a2387e 100644 --- a/google/auth/_regional_access_boundary_utils.py +++ b/google/auth/_regional_access_boundary_utils.py @@ -3,8 +3,11 @@ import datetime import threading +import logging + from google.auth import _helpers -from google.auth._default import _LOGGER + +_LOGGER = logging.getLogger(__name__) # The default lifetime for a cached Regional Access Boundary. diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 5d57d5775..9af8aa020 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -242,6 +242,7 @@ def with_scopes(self, scopes, default_scopes=None): def with_universe_domain(self, universe_domain): creds = self._make_copy() creds._universe_domain = universe_domain + creds._universe_domain_cached = True return creds diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 7ee7a6968..7caaca105 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -348,6 +348,7 @@ def with_scopes(self, scopes, default_scopes=None): scoped = self.__class__(**kwargs) scoped._cred_file_path = self._cred_file_path scoped._metrics_options = self._metrics_options + self._copy_regional_access_boundary_state(scoped) return scoped @abc.abstractmethod @@ -505,7 +506,11 @@ def _build_regional_access_boundary_lookup_url(self): return url else: # If both fail, the audience format is invalid. - raise exceptions.InvalidValue("Invalid audience format.") + _LOGGER.error( + "Invalid audience format for Regional Access Boundary lookup: %s", + self._audience, + ) + return None def _make_copy(self): kwargs = self._constructor_args() diff --git a/google/oauth2/_service_account_async.py b/google/oauth2/_service_account_async.py index 9067542f1..fa6cfb7b7 100644 --- a/google/oauth2/_service_account_async.py +++ b/google/oauth2/_service_account_async.py @@ -77,10 +77,10 @@ async def refresh(self, request): @_helpers.copy_docstring(credentials_async.Credentials) async def before_request(self, request, method, url, headers): + # Explicit override to bypass synchronous CredentialsWithRegionalAccessBoundary. await credentials_async.Credentials.before_request( self, request, method, url, headers ) - self._maybe_start_regional_access_boundary_refresh(request, url) class IDTokenCredentials( @@ -137,3 +137,11 @@ async def refresh(self, request): ) self.token = access_token self.expiry = expiry + + @_helpers.copy_docstring(credentials_async.Credentials) + async def before_request(self, request, method, url, headers): + # Explicit override to bypass synchronous CredentialsWithRegionalAccessBoundary + # and disable Regional Access Boundary refresh for async credentials. + await credentials_async.Credentials.before_request( + self, request, method, url, headers + ) diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py index 9d71a83fb..ad510f903 100644 --- a/tests/oauth2/test__client.py +++ b/tests/oauth2/test__client.py @@ -699,6 +699,7 @@ def test_lookup_regional_access_boundary_non_retryable_error(status_code): # Non-retryable errors should only be called once. mock_request.assert_called_once_with(method="GET", url=url, headers=headers) + def test_lookup_regional_access_boundary_internal_failure_and_retry_failure_error(): retryable_error = mock.create_autospec(transport.Response, instance=True) retryable_error.status = http_client.BAD_REQUEST diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 414b9aee5..3eea07355 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -242,7 +242,9 @@ def test_build_regional_access_boundary_lookup_url(self): credentials = self.make_credentials() expected_url = ( "https://iamcredentials.googleapis.com/v1/projects/-/" - "serviceAccounts/{}/allowedLocations".format(credentials.service_account_email) + "serviceAccounts/{}/allowedLocations".format( + credentials.service_account_email + ) ) assert credentials._build_regional_access_boundary_lookup_url() == expected_url diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 3aaef7834..30cd49b11 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -351,6 +351,16 @@ def test_with_scopes(self): assert scoped_credentials.has_scopes(["email"]) assert not scoped_credentials.requires_scopes + def test_with_scopes_copies_regional_access_boundary(self): + credentials = self.make_credentials() + credentials = credentials._with_regional_access_boundary( + self.VALID_TRUST_BOUNDARY + ) + scoped_credentials = credentials.with_scopes(["email"]) + + assert scoped_credentials.has_scopes(["email"]) + assert scoped_credentials._regional_access_boundary == self.VALID_TRUST_BOUNDARY + def test_with_scopes_workforce_pool(self): credentials = self.make_workforce_pool_credentials( workforce_pool_user_project=self.WORKFORCE_POOL_USER_PROJECT @@ -1750,8 +1760,7 @@ def test_build_regional_access_boundary_lookup_url_workforce(self): def test_build_regional_access_boundary_lookup_url_invalid_audience(self, audience): credentials = self.make_credentials() credentials._audience = audience - with pytest.raises(exceptions.InvalidValue, match="Invalid audience format."): - credentials._build_regional_access_boundary_lookup_url() + assert credentials._build_regional_access_boundary_lookup_url() is None def test_with_regional_access_boundary(self): credentials = self.make_credentials() From 8ea69b7ba34ccbc508a81f1404a64d0051ab7554 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:02:37 -0800 Subject: [PATCH 08/11] Remove unused _trust_boundary field and update unit tests. --- google/auth/credentials.py | 4 ---- google/oauth2/credentials.py | 6 ------ tests/oauth2/test_service_account.py | 4 ---- tests/test_aws.py | 5 ----- tests/test_credentials.py | 10 ++-------- tests/test_identity_pool.py | 3 --- tests/test_impersonated_credentials.py | 4 ++-- 7 files changed, 4 insertions(+), 32 deletions(-) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index adaae56e0..ea62b50c7 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -61,10 +61,6 @@ def __init__(self): If this is None, the token is assumed to never expire.""" self._quota_project_id = None """Optional[str]: Project to use for quota and billing purposes.""" - self._trust_boundary = None - """Optional[dict]: Cache of a trust boundary response which has a list - of allowed regions and an encoded string representation of credentials - trust boundary.""" self._universe_domain = DEFAULT_UNIVERSE_DOMAIN """Optional[str]: The universe domain value, default is googleapis.com """ diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index ae60223b4..5c8878f34 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -87,7 +87,6 @@ def __init__( refresh_handler=None, enable_reauth_refresh=False, granted_scopes=None, - trust_boundary=None, universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN, account=None, ): @@ -131,7 +130,6 @@ def __init__( granted_scopes (Optional[Sequence[str]]): The scopes that were consented/granted by the user. This could be different from the requested scopes and it could be empty if granted and requested scopes were same. - trust_boundary (str): String representation of trust boundary meta. universe_domain (Optional[str]): The universe domain. The default universe domain is googleapis.com. account (Optional[str]): The account associated with the credential. @@ -154,7 +152,6 @@ def __init__( self._rapt_token = rapt_token self.refresh_handler = refresh_handler self._enable_reauth_refresh = enable_reauth_refresh - self._trust_boundary = trust_boundary self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN self._account = account or "" self._cred_file_path = None @@ -192,7 +189,6 @@ def __setstate__(self, d): self._quota_project_id = d.get("_quota_project_id") self._rapt_token = d.get("_rapt_token") self._enable_reauth_refresh = d.get("_enable_reauth_refresh") - self._trust_boundary = d.get("_trust_boundary") self._universe_domain = ( d.get("_universe_domain") or credentials.DEFAULT_UNIVERSE_DOMAIN ) @@ -300,7 +296,6 @@ def _make_copy(self): quota_project_id=self.quota_project_id, rapt_token=self.rapt_token, enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, universe_domain=self._universe_domain, account=self._account, ) @@ -494,7 +489,6 @@ def from_authorized_user_info(cls, info, scopes=None): quota_project_id=info.get("quota_project_id"), # may not exist expiry=expiry, rapt_token=info.get("rapt_token"), # may not exist - trust_boundary=info.get("trust_boundary"), # may not exist universe_domain=info.get("universe_domain"), # may not exist account=info.get("account", ""), # may not exist ) diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 3eea07355..1d6eded2e 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -529,10 +529,6 @@ def test_refresh_success(self, jwt_grant): # Check that the credentials are valid (have a token and are not expired). assert credentials.valid - # Trust boundary should be None since env var is not set and no initial - # boundary was provided. - assert credentials._trust_boundary is None - @mock.patch("google.oauth2._client.jwt_grant", autospec=True) def test_before_request_refreshes(self, jwt_grant): credentials = self.make_credentials() diff --git a/tests/test_aws.py b/tests/test_aws.py index 477847525..a8f6fb152 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -2053,9 +2053,6 @@ def test_refresh_success_with_impersonation_ignore_default_scopes( "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), "x-goog-user-project": QUOTA_PROJECT_ID, "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - # TODO(negarb): Uncomment and update when trust boundary is supported - # for external account credentials. - # "x-allowed-locations": "0x0", } impersonation_request_data = { "delegates": None, @@ -2148,7 +2145,6 @@ def test_refresh_success_with_impersonation_use_default_scopes( "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), "x-goog-user-project": QUOTA_PROJECT_ID, "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - # "x-allowed-locations": "0x0", } impersonation_request_data = { "delegates": None, @@ -2343,7 +2339,6 @@ def test_refresh_success_with_supplier_with_impersonation( "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), "x-goog-user-project": QUOTA_PROJECT_ID, "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, - # "x-allowed-locations": "0x0", } impersonation_request_data = { "delegates": None, diff --git a/tests/test_credentials.py b/tests/test_credentials.py index c7555bbc6..b0cc65a53 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -533,20 +533,14 @@ def test_maybe_start_refresh_is_skipped_if_non_default_universe_domain( @mock.patch( "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" ) - @mock.patch("urllib.parse.urlparse") - def test_maybe_start_refresh_handles_url_parse_errors( - self, mock_urlparse, mock_start_refresh - ): - mock_urlparse.side_effect = ValueError("Malformed URL") + def test_maybe_start_refresh_handles_url_parse_errors(self, mock_start_refresh): creds = CredentialsImpl() request = mock.Mock() with mock.patch.dict( os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, ): - creds._maybe_start_regional_access_boundary_refresh( - request, "http://malformed-url" - ) + creds._maybe_start_regional_access_boundary_refresh(request, "http://[") mock_start_refresh.assert_called_once_with(creds, request) @mock.patch("google.oauth2._client._lookup_regional_access_boundary") diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index 2ffae6b8d..77b9de229 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -385,9 +385,6 @@ def assert_underlying_credentials_refresh( "Content-Type": "application/json", "authorization": "Bearer {}".format(token_response["access_token"]), "x-goog-api-client": metrics_header_value, - # TODO(negarb): Uncomment and update when trust boundary is supported - # for external account credentials. - # "x-allowed-locations": "0x0", } impersonation_request_data = { "delegates": None, diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 07e63d0bb..6c5dd4aaf 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -324,8 +324,8 @@ def test_refresh_success(self, use_data_bytes, mock_donor_credentials): == ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE ) - def test_refresh_source_creds_no_trust_boundary(self): - # Use a source credential that does not support trust boundaries. + def test_refresh_source_creds_no_regional_access_boundary(self): + # Use a source credential that does not support regional access boundaries. source_credentials = credentials.Credentials(token="source_token") creds = self.make_credentials(source_credentials=source_credentials) token = "impersonated_token" From 1cf7a80a4dd3a1dc71a8377b94d024872102ecdf Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:23:16 -0800 Subject: [PATCH 09/11] feat: add soft expiry logic and tests --- .../auth/_regional_access_boundary_utils.py | 4 ++++ google/auth/credentials.py | 8 +++++-- tests/test_credentials.py | 22 +++++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/google/auth/_regional_access_boundary_utils.py b/google/auth/_regional_access_boundary_utils.py index b31a2387e..4e3817e22 100644 --- a/google/auth/_regional_access_boundary_utils.py +++ b/google/auth/_regional_access_boundary_utils.py @@ -13,6 +13,10 @@ # The default lifetime for a cached Regional Access Boundary. DEFAULT_REGIONAL_ACCESS_BOUNDARY_TTL = datetime.timedelta(hours=6) +# The period of time prior to the boundary's expiration when a background refresh +# is proactively triggered. +REGIONAL_ACCESS_BOUNDARY_REFRESH_THRESHOLD = datetime.timedelta(hours=1) + # The initial cooldown period for a failed Regional Access Boundary lookup. DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN = datetime.timedelta(minutes=15) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index ea62b50c7..5629c3872 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -390,11 +390,15 @@ def _maybe_start_regional_access_boundary_refresh(self, request, url): if not self._is_regional_access_boundary_lookup_required(): return - # Don't start a new refresh if the Regional Access Boundary info is still valid. + # Don't start a new refresh if the Regional Access Boundary info is still fresh. + refresh_threshold = ( + _regional_access_boundary_utils.REGIONAL_ACCESS_BOUNDARY_REFRESH_THRESHOLD + ) if ( self._regional_access_boundary and self._regional_access_boundary_expiry - and _helpers.utcnow() < self._regional_access_boundary_expiry + and _helpers.utcnow() + < (self._regional_access_boundary_expiry - refresh_threshold) ): return diff --git a/tests/test_credentials.py b/tests/test_credentials.py index b0cc65a53..63b2fb8ad 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -397,9 +397,9 @@ def test_maybe_start_refresh_is_skipped_if_env_var_not_set( ) def test_maybe_start_refresh_is_skipped_if_not_expired(self, mock_start_refresh): creds = CredentialsImpl() - creds._regional_access_boundary = {"encodedLocations": "test"} + creds._regional_access_boundary = {"encodedLocations": "0xABC"} creds._regional_access_boundary_expiry = _helpers.utcnow() + datetime.timedelta( - minutes=5 + hours=2 ) with mock.patch.dict( os.environ, @@ -410,6 +410,24 @@ def test_maybe_start_refresh_is_skipped_if_not_expired(self, mock_start_refresh) ) mock_start_refresh.assert_not_called() + @mock.patch( + "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" + ) + def test_maybe_start_refresh_triggered_if_soft_expired(self, mock_start_refresh): + creds = CredentialsImpl() + creds._regional_access_boundary = {"encodedLocations": "0xABC"} + + creds._regional_access_boundary_expiry = _helpers.utcnow() + datetime.timedelta( + minutes=30 + ) + request = mock.Mock() + with mock.patch.dict( + os.environ, + {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, + ): + creds._maybe_start_regional_access_boundary_refresh(request, "http://example.com") + mock_start_refresh.assert_called_once_with(creds, request) + @mock.patch( "google.auth._regional_access_boundary_utils._RegionalAccessBoundaryRefreshManager.start_refresh" ) From ce53104fb0c466abede6534b444a871694504f36 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:42:26 -0800 Subject: [PATCH 10/11] fix lint issue --- tests/test_credentials.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 63b2fb8ad..993836ddb 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -425,7 +425,9 @@ def test_maybe_start_refresh_triggered_if_soft_expired(self, mock_start_refresh) os.environ, {environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED: "true"}, ): - creds._maybe_start_regional_access_boundary_refresh(request, "http://example.com") + creds._maybe_start_regional_access_boundary_refresh( + request, "http://example.com" + ) mock_start_refresh.assert_called_once_with(creds, request) @mock.patch( From 51be789176b29ecb4507c2d2aca495444be38843 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:06:21 -0800 Subject: [PATCH 11/11] fix: reorder imports in regional access boundary utils to pass lint --- google/auth/_regional_access_boundary_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/google/auth/_regional_access_boundary_utils.py b/google/auth/_regional_access_boundary_utils.py index 4e3817e22..c878a83a8 100644 --- a/google/auth/_regional_access_boundary_utils.py +++ b/google/auth/_regional_access_boundary_utils.py @@ -1,9 +1,8 @@ """Utilities for Regional Access Boundary management.""" import datetime -import threading - import logging +import threading from google.auth import _helpers