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] 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)