# -*- coding: utf-8 -*- # # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Utilities for the iamcredentials API.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import datetime import json from apitools.base.py import exceptions as apitools_exceptions from apitools.base.py import http_wrapper from googlecloudsdk.api_lib.util import apis_internal from googlecloudsdk.api_lib.util import exceptions from googlecloudsdk.core import exceptions as core_exceptions from googlecloudsdk.core import properties from googlecloudsdk.core import resources from googlecloudsdk.core import transport from oauth2client import client IAM_ENDPOINT_GDU = 'https://iamcredentials.googleapis.com/' class Error(core_exceptions.Error): """Exception that are defined by this module.""" class InvalidImpersonationAccount(Error): """Exception when the service account id is invalid.""" class ImpersonatedCredGoogleAuthRefreshError(Error): """Exception for google auth impersonated credentials refresh error.""" def GenerateAccessToken(service_account_id, scopes): """Generates an access token for the given service account.""" # pylint: disable=g-import-not-at-top from googlecloudsdk.core.credentials import transports # pylint: enable=g-import-not-at-top service_account_ref = resources.REGISTRY.Parse( service_account_id, collection='iamcredentials.serviceAccounts', params={'projectsId': '-', 'serviceAccountsId': service_account_id}) http_client = transports.GetApitoolsTransport( enable_resource_quota=False, response_encoding=transport.ENCODING, allow_account_impersonation=False) # pylint: disable=protected-access iam_client = apis_internal._GetClientInstance( 'iamcredentials', 'v1', http_client=http_client) try: response = iam_client.projects_serviceAccounts.GenerateAccessToken( iam_client.MESSAGES_MODULE .IamcredentialsProjectsServiceAccountsGenerateAccessTokenRequest( name=service_account_ref.RelativeName(), generateAccessTokenRequest=iam_client.MESSAGES_MODULE .GenerateAccessTokenRequest(scope=scopes) ) ) return response except apitools_exceptions.HttpForbiddenError as e: raise exceptions.HttpException( e, error_format='Error {code} (Forbidden) - failed to impersonate ' '[{service_acc}]. Make sure the account that\'s trying ' 'to impersonate it has access to the service account ' 'itself and the "roles/iam.serviceAccountTokenCreator" ' 'role.'.format( code=e.status_code, service_acc=service_account_id)) except apitools_exceptions.HttpError as e: raise exceptions.HttpException(e) def GenerateIdToken(service_account_id, audience, include_email=False): """Generates an id token for the given service account.""" # pylint: disable=g-import-not-at-top from googlecloudsdk.core.credentials import transports # pylint: enable=g-import-not-at-top service_account_ref = resources.REGISTRY.Parse( service_account_id, collection='iamcredentials.serviceAccounts', params={'projectsId': '-', 'serviceAccountsId': service_account_id}) http_client = transports.GetApitoolsTransport( enable_resource_quota=False, response_encoding=transport.ENCODING, allow_account_impersonation=False) # pylint: disable=protected-access iam_client = apis_internal._GetClientInstance( 'iamcredentials', 'v1', http_client=http_client) response = iam_client.projects_serviceAccounts.GenerateIdToken( iam_client.MESSAGES_MODULE .IamcredentialsProjectsServiceAccountsGenerateIdTokenRequest( name=service_account_ref.RelativeName(), generateIdTokenRequest=iam_client.MESSAGES_MODULE .GenerateIdTokenRequest(audience=audience, includeEmail=include_email) ) ) return response.token def GetEffectiveIamEndpoint(): """Returns the effective IAM endpoint. (1) If the [api_endpoint_overrides/iamcredentials] property is explicitly set, return the property value. (2) Otherwise if [core/universe_domain] value is not default, return "https://iamcredentials.{universe_domain_value}/". (3) Otherise return "https://iamcredentials.googleapis.com/" Returns: str: The effective IAM endpoint. """ if properties.VALUES.api_endpoint_overrides.iamcredentials.IsExplicitlySet(): return properties.VALUES.api_endpoint_overrides.iamcredentials.Get() universe_domain_property = properties.VALUES.core.universe_domain if universe_domain_property.Get() != universe_domain_property.default: return IAM_ENDPOINT_GDU.replace( 'googleapis.com', universe_domain_property.Get() ) return IAM_ENDPOINT_GDU class ImpersonationAccessTokenProvider(object): """A token provider for service account elevation. This supports the interface required by the core/credentials module. """ def GetElevationAccessToken(self, service_account_id, scopes): if ',' in service_account_id: raise InvalidImpersonationAccount( 'More than one service accounts were specified, ' 'which is not supported.') response = GenerateAccessToken(service_account_id, scopes) return ImpersonationCredentials( service_account_id, response.accessToken, response.expireTime, scopes) def GetElevationIdToken(self, service_account_id, audience, include_email): return GenerateIdToken(service_account_id, audience, include_email) def GetElevationAccessTokenGoogleAuth(self, source_credentials, target_principal, delegates, scopes): """Creates a fresh impersonation credential using google-auth library.""" # pylint: disable=g-import-not-at-top from google.auth import exceptions as google_auth_exceptions from google.auth import impersonated_credentials as google_auth_impersonated_credentials from googlecloudsdk.core import requests as core_requests # pylint: enable=g-import-not-at-top request_client = core_requests.GoogleAuthRequest() # google-auth makes a shadow copy of the source_credentials and refresh # the copy instead of the original source_credentials. During the copying, # the monkey patch # (creds.CredentialStoreWithCache._WrapCredentialsRefreshWithAutoCaching) # is lost. Here, before passing to google-auth, we refresh # source_credentials. source_credentials.refresh(request_client) cred = google_auth_impersonated_credentials.Credentials( source_credentials=source_credentials, target_principal=target_principal, target_scopes=scopes, delegates=delegates, ) self.PerformIamEndpointsOverride() try: cred.refresh(request_client) except google_auth_exceptions.RefreshError as e: original_message = ( "Failed to impersonate [{service_acc}]. Make sure the account that's" ' trying to impersonate it has access to the service account itself' ' and the "roles/iam.serviceAccountTokenCreator" role.'.format( service_acc=target_principal ) ) http_error = None # Try to convert RefreshError into a HttpError. try: # RefreshError has the content: # ( # "Unable to acquire impersonated credentials", # "'error': {'code': xxx, 'message': "xxx", 'details': [...]}" # ) # The refresh error's args[1] is the 2nd part (the json with 'error'). # It is a AIP-193 format error message. In the code below we refer to # the json part with 'error' as the "AIP-193 error message". content = json.loads(e.args[1]) # Prepend the original gcloud message to the AIP-193 error message. content['error']['message'] = ( original_message + ' ' + content['error']['message'] ) # Create HttpError with the modified AIP-193 error message. http_response = http_wrapper.Response( info={'status': content['error']['code']}, content=json.dumps(content), request_url=None, ) http_error = apitools_exceptions.HttpError.FromResponse(http_response) except Exception: # pylint: disable=broad-exception-caught pass if http_error: raise exceptions.HttpException( http_error, error_format='{message} {details?\n{?}}' ) # Fall back to RefreshError if we have trouble creating a HttpError. raise ImpersonatedCredGoogleAuthRefreshError(original_message) return cred def GetElevationIdTokenGoogleAuth(self, google_auth_impersonation_credentials, audience, include_email): """Creates an ID token credentials for impersonated credentials.""" # pylint: disable=g-import-not-at-top from google.auth import impersonated_credentials as google_auth_impersonated_credentials from googlecloudsdk.core import requests as core_requests # pylint: enable=g-import-not-at-top cred = google_auth_impersonated_credentials.IDTokenCredentials( google_auth_impersonation_credentials, target_audience=audience, include_email=include_email, ) request_client = core_requests.GoogleAuthRequest() self.PerformIamEndpointsOverride() cred.refresh(request_client) return cred @classmethod def IsImpersonationCredential(cls, cred): # pylint: disable=g-import-not-at-top from google.auth import impersonated_credentials as google_auth_impersonated_credentials # pylint: enable=g-import-not-at-top return isinstance(cred, ImpersonationCredentials) or isinstance( cred, google_auth_impersonated_credentials.Credentials ) @classmethod def PerformIamEndpointsOverride(cls): """Perform IAM endpoint override if needed. We will override IAM generateAccessToken, signBlob, and generateIdToken endpoint under the following conditions. (1) If the [api_endpoint_overrides/iamcredentials] property is explicitly set, we replace "https://iamcredentials.googleapis.com/" with the given property value in these endpoints. (2) If the property above is not set, and the [core/universe_domain] value is not default, we replace "googleapis.com" with the [core/universe_domain] property value in these endpoints. """ # pylint: disable=g-import-not-at-top from google.auth import iam as google_auth_iam # pylint: enable=g-import-not-at-top effective_iam_endpoint = GetEffectiveIamEndpoint() google_auth_iam._IAM_ENDPOINT = ( # pylint: disable=protected-access google_auth_iam._IAM_ENDPOINT.replace( # pylint: disable=protected-access IAM_ENDPOINT_GDU, effective_iam_endpoint, ) ) google_auth_iam._IAM_SIGN_ENDPOINT = ( # pylint: disable=protected-access google_auth_iam._IAM_SIGN_ENDPOINT.replace( # pylint: disable=protected-access IAM_ENDPOINT_GDU, effective_iam_endpoint, ) ) google_auth_iam._IAM_IDTOKEN_ENDPOINT = ( # pylint: disable=protected-access google_auth_iam._IAM_IDTOKEN_ENDPOINT.replace( # pylint: disable=protected-access IAM_ENDPOINT_GDU, effective_iam_endpoint, ) ) class ImpersonationCredentials(client.OAuth2Credentials): """Implementation of a credential that refreshes using the iamcredentials API. """ _EXPIRY_FORMAT = '%Y-%m-%dT%H:%M:%SZ' def __init__(self, service_account_id, access_token, token_expiry, scopes): self._service_account_id = service_account_id token_expiry = self._ConvertExpiryTime(token_expiry) super(ImpersonationCredentials, self).__init__( access_token, None, None, None, token_expiry, None, None, scopes=scopes) def _refresh(self, http): # client.OAuth2Credentials converts scopes into a set, so we need to convert # back to a list before making the API request. response = GenerateAccessToken(self._service_account_id, list(self.scopes)) self.access_token = response.accessToken self.token_expiry = self._ConvertExpiryTime(response.expireTime) def _ConvertExpiryTime(self, value): return datetime.datetime.strptime(value, ImpersonationCredentials._EXPIRY_FORMAT)