From c872ca6ef2f771ae01a6f633391a44797cf02d0a Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Fri, 12 Oct 2018 15:15:49 -0700 Subject: [PATCH 01/15] Add delegate_credentials --- google/auth/delegate_credentials.py | 157 ++++++++++++++++++++++++++++ tests/test_delegate_credentials.py | 148 ++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 google/auth/delegate_credentials.py create mode 100644 tests/test_delegate_credentials.py diff --git a/google/auth/delegate_credentials.py b/google/auth/delegate_credentials.py new file mode 100644 index 000000000..546ed4811 --- /dev/null +++ b/google/auth/delegate_credentials.py @@ -0,0 +1,157 @@ +# Copyright 2018 Google Inc. +# +# 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. + +"""Google Cloud Delegated credentials. + +This module provides authentication for applications where local credentials +impersonates a remote service account using `IAM Credentials API`_. + +This class can be used to impersonate a service account as long as the original +Credential object as the "Service Account Token Creator" role on the target +service account. + +.. _IAM Credentials API: + https://cloud.google.com/iam/credentials/reference/rest/ + +""" + +import json +import copy +import datetime + +from google.auth import _helpers +from google.auth import credentials + +from google.auth.transport.requests import Request, AuthorizedSession +from google.auth import exceptions + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds + +_IAM_SCOPE = ['https://www.googleapis.com/auth/iam'] + +# Number of seconds before token expiration to initiate a refresh +_CLOCK_SKEW_SECS = 30 +_CLOCK_SKEW = datetime.timedelta(seconds=_CLOCK_SKEW_SECS) + +class DelegateCredentials(credentials.Credentials): + """Delegate credentials + + These are delegate credentials which are essentially impersonated identities. + + When the `Service Account Token Creator` IAM role is granted to a service account, + any other identity that has that capability can impersonate that service account. + For more information about Token Creator IAM role and IAMCredentials API, see + `IAM documentation`_. + + .. _The Service Account Token Creator role: + https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role + + Usage: + First grant root_credentials the `Service Account Token Creator` role on + the account to impersonate. In this example, the service account represented by + svc_account.json has the token creator role on + `impersonated-account@_project_.iam.gserviceaccount.com`. + + Second, enable 'iamcredentials.googleapis.com' API on the project represented by + `svc_account.json` + + # initialize root_credentials for any scope/usage + scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/svc_account.json' + root_credentials, project = google.auth.default(scopes=scopes) + + # now use the root_credentials to initalize a delegate_credentials + new_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + delegate_credentials = DelegateCredentials( + root_credentials = apex_credentials, + principal='impersonated-account@_project_.iam.gserviceaccount.com', + new_scopes = new_scopes, + delegates=[], + lifetime=500) + + # now list the bucket as the delegate. The root credential does + # not need to have access to this project but the delegate does + client = storage.Client(credentials=delegate_credentials) + buckets = client.list_buckets(project='your_project') + for bkt in buckets: + print bkt.name + """ + + def __init__(self, root_credentials, principal, + new_scopes, delegates=None, + lifetime=_DEFAULT_TOKEN_LIFETIME_SECS): + """ + Args: + root_credentials (google.auth.Credentials): The root credential + used as to acquire the delegated credentials. + principal (str): The service account to impersonatge. + new_scopes (Sequence[str]): Scopes to request during the authorization + grant. + delegates (Sequence[str]): The chained list of delegates required to grant + the final access_token + lifetime (int): Number of seconds the delegated credential should be valid for + (max 3600). + """ + + super(credentials.Credentials, self).__init__() + + self._root_credentials = copy.copy(root_credentials) + self._root_credentials._scopes = _IAM_SCOPE + self._principal = principal + self._new_scopes = new_scopes + self._delegates = delegates + self._lifetime = lifetime + self.token = None + self.expiry = _helpers.utcnow() + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + self._root_credentials.refresh(request) + self._updateToken() + + @property + def expired(self): + skewed_expiry = self.expiry - _CLOCK_SKEW + return _helpers.utcnow() >= skewed_expiry + + def _updateToken(self): + """Updates the delegate credentials with a new access_token representing + the delegated account. + + Raises: + DefaultCredentialsError: Raised if the delegated credentials are not + available. Common reasons are `iamcredentials.googleapis.com` is + not enabled or the `Service Account Token Creator` is not assigned + """ + req = Request() + self._root_credentials.refresh(req) + + body={ + "delegates": self._delegates, + "scope": self._new_scopes, + "lifetime": str(self._lifetime) + "s" + } + iam_endpoint = ('https://iamcredentials.googleapis.com/v1/projects/' + '-/serviceAccounts/{}:generateAccessToken'.format(self._principal)) + try: + authed_session = AuthorizedSession(self._root_credentials) + response = authed_session.post(iam_endpoint, headers={'Content-Type': 'application/json'}, json=body) + if (response.status_code == 200): + token_response = json.loads(response.content) + self.token = token_response['accessToken'] + self.expiry = datetime.datetime.strptime(token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') + else: + raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + self._principal) + except (exceptions.TransportError, ValueError, KeyError) as e: + raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + self._principal) \ No newline at end of file diff --git a/tests/test_delegate_credentials.py b/tests/test_delegate_credentials.py new file mode 100644 index 000000000..6ea88c6cd --- /dev/null +++ b/tests/test_delegate_credentials.py @@ -0,0 +1,148 @@ +# Copyright 2018 Google Inc. +# +# 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. + +import datetime +import json +import os +import mock +import pytest + +from six.moves import http_client + +from google.auth import exceptions +from google.auth.delegate_credentials import DelegateCredentials + +from google.auth import _helpers +from google.auth import crypt +from google.auth import transport +from google.oauth2 import service_account + +DATA_DIR = os.path.join(os.path.dirname(__file__), '', 'data') + +with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh: + PRIVATE_KEY_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, 'service_account.json') + +with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + +SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1') + +class TestDelegateCredentials(object): + SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' + IMPERSONATED_ACCOUNT = 'impersonated-account@project.iam.gserviceaccount.com' + NEW_SCOPES = ['https://www.googleapis.com/auth/devstorage.read_only'] + DELEGATES = [] + NEW_SCOPES = 3600 + + TOKEN_URI = 'https://example.com/oauth2/token' + + @classmethod + def make_credentials(cls): + root_credentials = service_account.Credentials( + SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI) + return DelegateCredentials( + root_credentials = root_credentials, + principal = cls.IMPERSONATED_ACCOUNT, + new_scopes = cls.NEW_SCOPES, + delegates = cls.DELEGATES, + lifetime = cls.NEW_SCOPES) + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + assert credentials.expired + + + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) + def test_refresh_success(self, jwt_grant): + credentials = self.make_credentials() + token = 'token' + + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}) + + request_body={ + "delegates": credentials._delegates, + "scope": credentials._new_scopes, + "lifetime": str(credentials._lifetime) + "s" + } + response_body={ + "accessToken": token, + "expireTime": (_helpers.utcnow() + datetime.timedelta(seconds=credentials._lifetime)).isoformat('T') + 'Z' + } + + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.data = _helpers.to_bytes( json.dumps(response_body) ) + + request = mock.create_autospec(transport.Request, instance=True) + request.data = _helpers.to_bytes( json.dumps(request_body) ) + request.return_value = response + + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) + def test_refresh_failure(self, jwt_grant): + credentials = self.make_credentials() + token = 'token' + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}) + + body={ + "delegates": credentials._delegates, + "scope": credentials._new_scopes, + "lifetime": str(credentials._lifetime) + "s" + } + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.NOT_FOUND + response.headers = {} + request = mock.create_autospec(transport.Request) + request.return_value = response + request.headers = {} + request.data = _helpers.to_bytes( json.dumps(body) ) + + #with pytest.raises(exceptions.TransportError) as excinfo: + # credentials.refresh(request) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(r'Unable to acquire delegated credentials ') + assert not credentials.valid + assert credentials.expired + + def test_expired(self): + credentials = self.make_credentials() + assert credentials.expired + + + + def test___updateToken(self): + credentials = self.make_credentials() + + body={ + "delegates": credentials._delegates, + "scope": credentials._new_scopes, + "lifetime": str(credentials._lifetime) + "s" + } + \ No newline at end of file From cadefac7563bb628004f21cdd908f38ee1cc3d53 Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Fri, 12 Oct 2018 19:01:14 -0700 Subject: [PATCH 02/15] add docs; comment refresh test --- docs/index.rst | 1 + .../google.auth.delegate_credentials.rst | 7 + docs/reference/google.auth.rst | 1 + docs/user-guide.rst | 34 +++++ google/auth/delegate_credentials.py | 126 ++++++++++-------- tests/test_delegate_credentials.py | 68 ++++------ 6 files changed, 143 insertions(+), 94 deletions(-) create mode 100644 docs/reference/google.auth.delegate_credentials.rst diff --git a/docs/index.rst b/docs/index.rst index 56e3ecaf6..21e4feaad 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ also provides integration with several HTTP libraries. - Support for signing and verifying :mod:`JWTs `. - Support for verifying and decoding :mod:`ID Tokens `. - Support for Google :mod:`Service Account credentials `. +- Support for Google :mod:`Delegate Credentials `. - Support for :mod:`Google Compute Engine credentials `. - Support for :mod:`Google App Engine standard credentials `. - Support for various transports, including diff --git a/docs/reference/google.auth.delegate_credentials.rst b/docs/reference/google.auth.delegate_credentials.rst new file mode 100644 index 000000000..1cb2d2a61 --- /dev/null +++ b/docs/reference/google.auth.delegate_credentials.rst @@ -0,0 +1,7 @@ +google.auth.delegate\_credentials module +======================================== + +.. automodule:: google.auth.delegate_credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.rst b/docs/reference/google.auth.rst index 244d0bbd3..3242f21b5 100644 --- a/docs/reference/google.auth.rst +++ b/docs/reference/google.auth.rst @@ -22,6 +22,7 @@ Submodules google.auth.app_engine google.auth.credentials google.auth.crypt + google.auth.delegate_credentials google.auth.environment_vars google.auth.exceptions google.auth.iam diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 060d9b84a..91c2ab435 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -205,6 +205,40 @@ You can also use :class:`google_auth_oauthlib.flow.Flow` to perform the OAuth .. _requests-oauthlib: https://requests-oauthlib.readthedocs.io/en/latest/ +Delegate credentials +++++++++++++++++++++ + +Delegate Credentials allows one set of credentials issued to a user or service account +to impersonate another. The target service account must grant the orginating credential +principal the "Service Account Token Creator" IAM role:: + + from google.auth.delegate_credentials import DelegateCredentials + + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/svc_account.json' + scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + + root_credentials, project = google.auth.default(scopes=scopes) + client = storage.Client(credentials=root_credentials) + buckets = client.list_buckets(project='your_project') + for bkt in buckets: + print bkt + new_scopes = scopes + delegate_credentials = DelegateCredentials( + root_credentials = root_credentials, + principal='impersonated-account@_project_.iam.gserviceaccount.com', + new_scopes = new_scopes, + delegates=[], + lifetime=500) + client = storage.Client(credentials=delegate_credentials) + buckets = client.list_buckets(project='your_project') + for bkt in buckets: + print bkt.name + + +In the example above `root_credentials` does not have direct access to list buckets +in the target project. Using `Delegate Credentials` will allow the root_credentials +to assume the identity of a principal that does have access + Making authenticated requests ----------------------------- diff --git a/google/auth/delegate_credentials.py b/google/auth/delegate_credentials.py index 546ed4811..0b1fdfe47 100644 --- a/google/auth/delegate_credentials.py +++ b/google/auth/delegate_credentials.py @@ -14,29 +14,28 @@ """Google Cloud Delegated credentials. -This module provides authentication for applications where local credentials +This module provides authentication for applications where local credentials impersonates a remote service account using `IAM Credentials API`_. This class can be used to impersonate a service account as long as the original -Credential object as the "Service Account Token Creator" role on the target +Credential object as the "Service Account Token Creator" role on the target service account. -.. _IAM Credentials API: - https://cloud.google.com/iam/credentials/reference/rest/ - + .. _IAM Credentials API: + https://cloud.google.com/iam/credentials/reference/rest/ """ -import json import copy import datetime +import json from google.auth import _helpers from google.auth import credentials +from google.auth import exceptions -from google.auth.transport.requests import Request, AuthorizedSession -from google.auth import exceptions +from google.auth.transport.requests import AuthorizedSession, Request -_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds _IAM_SCOPE = ['https://www.googleapis.com/auth/iam'] @@ -44,44 +43,57 @@ _CLOCK_SKEW_SECS = 30 _CLOCK_SKEW = datetime.timedelta(seconds=_CLOCK_SKEW_SECS) -class DelegateCredentials(credentials.Credentials): - """Delegate credentials +_IAM_CREDENTIALS_ENDPOINT = 'https://iamcredentials.googleapis.com/v1/projects/' + - These are delegate credentials which are essentially impersonated identities. +class DelegateCredentials(credentials.Credentials): + """This module defines delegate credentials which are essentially + impersonated identities. - When the `Service Account Token Creator` IAM role is granted to a service account, - any other identity that has that capability can impersonate that service account. - For more information about Token Creator IAM role and IAMCredentials API, see - `IAM documentation`_. + When the `Service Account Token Creator_` IAM role is granted to a + service account, any other identity that has that capability can + impersonate that service account. + For more information about Token Creator IAM role and + IAMCredentials API, see `IAM documentation`. - .. _The Service Account Token Creator role: + .. _Service Account Token Creator: https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role Usage: - First grant root_credentials the `Service Account Token Creator` role on - the account to impersonate. In this example, the service account represented by - svc_account.json has the token creator role on + First grant root_credentials the `Service Account Token Creator` + role on the account to impersonate. In this example, the + service account represented by svc_account.json has the + token creator role on `impersonated-account@_project_.iam.gserviceaccount.com`. - Second, enable 'iamcredentials.googleapis.com' API on the project represented by - `svc_account.json` + Second, enable `iamcredentials.googleapis.com` API on the project + represented by `svc_account.json` + + + First initialze a root credential which does not have access to list bucket:: - # initialize root_credentials for any scope/usage - scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/svc_account.json' - root_credentials, project = google.auth.default(scopes=scopes) + scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] - # now use the root_credentials to initalize a delegate_credentials - new_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + root_credentials, project = google.auth.default(scopes=scopes) + client = storage.Client(credentials=root_credentials) + buckets = client.list_buckets(project='your_project') + for bkt in buckets: + print bkt + + + Now use the root credentials to acquire credentials to impersonate another user:: + + new_scopes = scopes delegate_credentials = DelegateCredentials( - root_credentials = apex_credentials, - principal='impersonated-account@_project_.iam.gserviceaccount.com', - new_scopes = new_scopes, - delegates=[], - lifetime=500) - - # now list the bucket as the delegate. The root credential does - # not need to have access to this project but the delegate does + root_credentials = root_credentials, + principal='impersonated-account@_project_.iam.gserviceaccount.com', + new_scopes = new_scopes, + delegates=[], + lifetime=500) + + Resource access is granted:: + client = storage.Client(credentials=delegate_credentials) buckets = client.list_buckets(project='your_project') for bkt in buckets: @@ -89,20 +101,20 @@ class DelegateCredentials(credentials.Credentials): """ def __init__(self, root_credentials, principal, - new_scopes, delegates=None, - lifetime=_DEFAULT_TOKEN_LIFETIME_SECS): + new_scopes, delegates=None, + lifetime=_DEFAULT_TOKEN_LIFETIME_SECS): """ Args: root_credentials (google.auth.Credentials): The root credential used as to acquire the delegated credentials. principal (str): The service account to impersonatge. - new_scopes (Sequence[str]): Scopes to request during the authorization - grant. - delegates (Sequence[str]): The chained list of delegates required to grant - the final access_token - lifetime (int): Number of seconds the delegated credential should be valid for - (max 3600). - """ + new_scopes (Sequence[str]): Scopes to request during the + authorization grant. + delegates (Sequence[str]): The chained list of delegates required + to grant the final access_token. + lifetime (int): Number of seconds the delegated credential should + be valid for (max 3600). + """ super(credentials.Credentials, self).__init__() @@ -126,32 +138,36 @@ def expired(self): return _helpers.utcnow() >= skewed_expiry def _updateToken(self): - """Updates the delegate credentials with a new access_token representing + """Updates the delegate credentials with a new access_token representing the delegated account. Raises: - DefaultCredentialsError: Raised if the delegated credentials are not - available. Common reasons are `iamcredentials.googleapis.com` is - not enabled or the `Service Account Token Creator` is not assigned + DefaultCredentialsError: Raised if the delegated credentials + are not available. Common reasons are + `iamcredentials.googleapis.com` is not enabled or the + `Service Account Token Creator` is not assigned """ req = Request() self._root_credentials.refresh(req) - body={ + body = { "delegates": self._delegates, "scope": self._new_scopes, "lifetime": str(self._lifetime) + "s" } - iam_endpoint = ('https://iamcredentials.googleapis.com/v1/projects/' - '-/serviceAccounts/{}:generateAccessToken'.format(self._principal)) + iam_endpoint = ('{}-/serviceAccounts/{}:generateAccessToken').format(_IAM_CREDENTIALS_ENDPOINT, self._principal) try: authed_session = AuthorizedSession(self._root_credentials) - response = authed_session.post(iam_endpoint, headers={'Content-Type': 'application/json'}, json=body) + response = authed_session.post(iam_endpoint, + headers={'Content-Type': 'application/json'}, + json=body) if (response.status_code == 200): token_response = json.loads(response.content) self.token = token_response['accessToken'] self.expiry = datetime.datetime.strptime(token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') else: - raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + self._principal) - except (exceptions.TransportError, ValueError, KeyError) as e: - raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + self._principal) \ No newline at end of file + raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + + self._principal) + except (exceptions.TransportError, ValueError, KeyError): + raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + + self._principal) diff --git a/tests/test_delegate_credentials.py b/tests/test_delegate_credentials.py index 6ea88c6cd..db1823a7b 100644 --- a/tests/test_delegate_credentials.py +++ b/tests/test_delegate_credentials.py @@ -15,17 +15,18 @@ import datetime import json import os + import mock import pytest from six.moves import http_client -from google.auth import exceptions -from google.auth.delegate_credentials import DelegateCredentials - from google.auth import _helpers from google.auth import crypt +from google.auth import exceptions from google.auth import transport + +from google.auth.delegate_credentials import DelegateCredentials from google.oauth2 import service_account DATA_DIR = os.path.join(os.path.dirname(__file__), '', 'data') @@ -40,7 +41,9 @@ SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1') + class TestDelegateCredentials(object): + SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' IMPERSONATED_ACCOUNT = 'impersonated-account@project.iam.gserviceaccount.com' NEW_SCOPES = ['https://www.googleapis.com/auth/devstorage.read_only'] @@ -54,17 +57,16 @@ def make_credentials(cls): root_credentials = service_account.Credentials( SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI) return DelegateCredentials( - root_credentials = root_credentials, - principal = cls.IMPERSONATED_ACCOUNT, - new_scopes = cls.NEW_SCOPES, - delegates = cls.DELEGATES, - lifetime = cls.NEW_SCOPES) + root_credentials=root_credentials, + principal=cls.IMPERSONATED_ACCOUNT, + new_scopes=cls.NEW_SCOPES, + delegates=cls.DELEGATES, + lifetime=cls.NEW_SCOPES) def test_default_state(self): credentials = self.make_credentials() assert not credentials.valid - assert credentials.expired - + assert credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): @@ -76,28 +78,31 @@ def test_refresh_success(self, jwt_grant): _helpers.utcnow() + datetime.timedelta(seconds=500), {}) - request_body={ + request_body = { "delegates": credentials._delegates, "scope": credentials._new_scopes, "lifetime": str(credentials._lifetime) + "s" } - response_body={ + response_body = { "accessToken": token, - "expireTime": (_helpers.utcnow() + datetime.timedelta(seconds=credentials._lifetime)).isoformat('T') + 'Z' - } - + "expireTime": (_helpers.utcnow() + + datetime.timedelta(seconds=credentials._lifetime) + ).isoformat('T') + 'Z' + } + response = mock.create_autospec(transport.Response, instance=True) response.status = http_client.OK - response.data = _helpers.to_bytes( json.dumps(response_body) ) + response.data = _helpers.to_bytes(json.dumps(response_body)) request = mock.create_autospec(transport.Request, instance=True) - request.data = _helpers.to_bytes( json.dumps(request_body) ) + request.data = _helpers.to_bytes(json.dumps(request_body)) request.return_value = response - credentials.refresh(request) + # this test should pass...commenting out test for now + #credentials.refresh(request) - assert credentials.valid - assert not credentials.expired + #assert credentials.valid + #assert not credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure(self, jwt_grant): @@ -108,7 +113,7 @@ def test_refresh_failure(self, jwt_grant): _helpers.utcnow() + datetime.timedelta(seconds=500), {}) - body={ + body = { "delegates": credentials._delegates, "scope": credentials._new_scopes, "lifetime": str(credentials._lifetime) + "s" @@ -119,30 +124,15 @@ def test_refresh_failure(self, jwt_grant): request = mock.create_autospec(transport.Request) request.return_value = response request.headers = {} - request.data = _helpers.to_bytes( json.dumps(body) ) - - #with pytest.raises(exceptions.TransportError) as excinfo: - # credentials.refresh(request) + request.data = _helpers.to_bytes(json.dumps(body)) with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: - credentials.refresh(request) + credentials.refresh(request) assert excinfo.match(r'Unable to acquire delegated credentials ') assert not credentials.valid - assert credentials.expired + assert credentials.expired def test_expired(self): credentials = self.make_credentials() assert credentials.expired - - - - def test___updateToken(self): - credentials = self.make_credentials() - - body={ - "delegates": credentials._delegates, - "scope": credentials._new_scopes, - "lifetime": str(credentials._lifetime) + "s" - } - \ No newline at end of file From 54fa1afecc7747262d9b441a2a50d05a6fbb5760 Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Sat, 13 Oct 2018 11:57:20 -0700 Subject: [PATCH 03/15] fix lint; fix testcases --- google/auth/delegate_credentials.py | 57 +++++++++++------- tests/test_delegate_credentials.py | 89 ++++++++++++++++++----------- 2 files changed, 91 insertions(+), 55 deletions(-) diff --git a/google/auth/delegate_credentials.py b/google/auth/delegate_credentials.py index 0b1fdfe47..c20327b60 100644 --- a/google/auth/delegate_credentials.py +++ b/google/auth/delegate_credentials.py @@ -26,14 +26,13 @@ """ import copy -import datetime +from datetime import datetime, timedelta import json from google.auth import _helpers from google.auth import credentials from google.auth import exceptions -from google.auth.transport.requests import AuthorizedSession, Request _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds @@ -41,9 +40,12 @@ # Number of seconds before token expiration to initiate a refresh _CLOCK_SKEW_SECS = 30 -_CLOCK_SKEW = datetime.timedelta(seconds=_CLOCK_SKEW_SECS) +_CLOCK_SKEW = timedelta(seconds=_CLOCK_SKEW_SECS) -_IAM_CREDENTIALS_ENDPOINT = 'https://iamcredentials.googleapis.com/v1/projects/' +_IAM_ENDPOINT = ('https://iamcredentials.googleapis.com/v1/projects/-' + + '/serviceAccounts/{}:generateAccessToken') + +_REFRESH_ERROR_MSG = 'Unable to acquire delegated credentials ' class DelegateCredentials(credentials.Credentials): @@ -70,11 +72,12 @@ class DelegateCredentials(credentials.Credentials): represented by `svc_account.json` - First initialze a root credential which does not have access to list bucket:: + First initialze a root credential which does not have access to + list bucket:: - os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/svc_account.json' + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'svc_account.json' scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] - + root_credentials, project = google.auth.default(scopes=scopes) client = storage.Client(credentials=root_credentials) buckets = client.list_buckets(project='your_project') @@ -82,7 +85,8 @@ class DelegateCredentials(credentials.Credentials): print bkt - Now use the root credentials to acquire credentials to impersonate another user:: + Now use the root credentials to acquire credentials to impersonate + another user:: new_scopes = scopes delegate_credentials = DelegateCredentials( @@ -91,7 +95,7 @@ class DelegateCredentials(credentials.Credentials): new_scopes = new_scopes, delegates=[], lifetime=500) - + Resource access is granted:: client = storage.Client(credentials=delegate_credentials) @@ -130,24 +134,27 @@ def __init__(self, root_credentials, principal, @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): self._root_credentials.refresh(request) - self._updateToken() + self._updateToken(request) @property def expired(self): skewed_expiry = self.expiry - _CLOCK_SKEW return _helpers.utcnow() >= skewed_expiry - def _updateToken(self): + def _updateToken(self, req): """Updates the delegate credentials with a new access_token representing the delegated account. + Args: + req (google.auth.transport.requests.Request): Request object to use + for refreshing credentials. + Raises: DefaultCredentialsError: Raised if the delegated credentials are not available. Common reasons are `iamcredentials.googleapis.com` is not enabled or the `Service Account Token Creator` is not assigned """ - req = Request() self._root_credentials.refresh(req) body = { @@ -155,19 +162,25 @@ def _updateToken(self): "scope": self._new_scopes, "lifetime": str(self._lifetime) + "s" } - iam_endpoint = ('{}-/serviceAccounts/{}:generateAccessToken').format(_IAM_CREDENTIALS_ENDPOINT, self._principal) + + iam_endpoint = _IAM_ENDPOINT.format(self._principal) try: - authed_session = AuthorizedSession(self._root_credentials) - response = authed_session.post(iam_endpoint, - headers={'Content-Type': 'application/json'}, - json=body) - if (response.status_code == 200): - token_response = json.loads(response.content) + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + self._root_credentials.token + } + response = req(url=iam_endpoint, + method='POST', + headers=headers, + json=body) + if (response.status == 200): + token_response = json.loads(response.data) self.token = token_response['accessToken'] - self.expiry = datetime.datetime.strptime(token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') + self.expiry = datetime.strptime(token_response['expireTime'], + '%Y-%m-%dT%H:%M:%SZ') else: - raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + + raise exceptions.DefaultCredentialsError(_REFRESH_ERROR_MSG + self._principal) except (exceptions.TransportError, ValueError, KeyError): - raise exceptions.DefaultCredentialsError("Unable to acquire delegated credentials " + + raise exceptions.DefaultCredentialsError(_REFRESH_ERROR_MSG + self._principal) diff --git a/tests/test_delegate_credentials.py b/tests/test_delegate_credentials.py index db1823a7b..c6781e5b5 100644 --- a/tests/test_delegate_credentials.py +++ b/tests/test_delegate_credentials.py @@ -18,14 +18,12 @@ import mock import pytest - from six.moves import http_client from google.auth import _helpers from google.auth import crypt from google.auth import exceptions from google.auth import transport - from google.auth.delegate_credentials import DelegateCredentials from google.oauth2 import service_account @@ -45,7 +43,7 @@ class TestDelegateCredentials(object): SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' - IMPERSONATED_ACCOUNT = 'impersonated-account@project.iam.gserviceaccount.com' + IMPERSONATED_ACCOUNT = 'impersonated@project.iam.gserviceaccount.com' NEW_SCOPES = ['https://www.googleapis.com/auth/devstorage.read_only'] DELEGATES = [] NEW_SCOPES = 3600 @@ -68,6 +66,19 @@ def test_default_state(self): assert not credentials.valid assert credentials.expired + def make_request(self, data, status=http_client.OK, + headers=None, side_effect=None): + response = mock.create_autospec(transport.Response, instance=False) + response.status = status + response.data = _helpers.to_bytes(data) + response.headers = headers or {} + + request = mock.create_autospec(transport.Request, instance=False) + request.side_effect = side_effect + request.return_value = response + + return request + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): credentials = self.make_credentials() @@ -78,58 +89,70 @@ def test_refresh_success(self, jwt_grant): _helpers.utcnow() + datetime.timedelta(seconds=500), {}) - request_body = { - "delegates": credentials._delegates, - "scope": credentials._new_scopes, - "lifetime": str(credentials._lifetime) + "s" - } response_body = { "accessToken": token, - "expireTime": (_helpers.utcnow() + + "expireTime": (_helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=credentials._lifetime) ).isoformat('T') + 'Z' } - response = mock.create_autospec(transport.Response, instance=True) - response.status = http_client.OK - response.data = _helpers.to_bytes(json.dumps(response_body)) + request = self.make_request(data=json.dumps(response_body), + status=http_client.OK) + credentials.refresh(request) - request = mock.create_autospec(transport.Request, instance=True) - request.data = _helpers.to_bytes(json.dumps(request_body)) - request.return_value = response + assert credentials.valid + assert not credentials.expired - # this test should pass...commenting out test for now - #credentials.refresh(request) + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) + def test_refresh_failure_unauthorzed(self, jwt_grant): + credentials = self.make_credentials() + token = 'token' - #assert credentials.valid - #assert not credentials.expired + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}) + + response_body = { + "error": { + "code": 403, + "message": "The caller does not have permission", + "status": "PERMISSION_DENIED" + } + } + + request = self.make_request(data=json.dumps(response_body), + status=http_client.UNAUTHORIZED) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(r'Unable to acquire delegated credentials') + + assert not credentials.valid + assert credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) - def test_refresh_failure(self, jwt_grant): + def test_refresh_failure_http_error(self, jwt_grant): credentials = self.make_credentials() token = 'token' + jwt_grant.return_value = ( token, _helpers.utcnow() + datetime.timedelta(seconds=500), {}) - body = { - "delegates": credentials._delegates, - "scope": credentials._new_scopes, - "lifetime": str(credentials._lifetime) + "s" - } - response = mock.create_autospec(transport.Response, instance=True) - response.status = http_client.NOT_FOUND - response.headers = {} - request = mock.create_autospec(transport.Request) - request.return_value = response - request.headers = {} - request.data = _helpers.to_bytes(json.dumps(body)) + response_body = {} + + request = self.make_request(data=json.dumps(response_body), + status=http_client.HTTPException, + side_effect=exceptions.TransportError) with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: credentials.refresh(request) - assert excinfo.match(r'Unable to acquire delegated credentials ') + assert excinfo.match(r'Unable to acquire delegated credentials') + assert not credentials.valid assert credentials.expired From 7a99cd545c15f784fc94d02de670c191a6cc14d9 Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Sat, 13 Oct 2018 12:09:53 -0700 Subject: [PATCH 04/15] encode --- google/auth/delegate_credentials.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/auth/delegate_credentials.py b/google/auth/delegate_credentials.py index c20327b60..66e3caa9a 100644 --- a/google/auth/delegate_credentials.py +++ b/google/auth/delegate_credentials.py @@ -174,13 +174,13 @@ def _updateToken(self, req): headers=headers, json=body) if (response.status == 200): - token_response = json.loads(response.data) + token_response = json.loads(response.data.decode('utf-8')) self.token = token_response['accessToken'] self.expiry = datetime.strptime(token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') else: raise exceptions.DefaultCredentialsError(_REFRESH_ERROR_MSG + self._principal) - except (exceptions.TransportError, ValueError, KeyError): + except (exceptions.TransportError, ValueError, KeyError, TypeError): raise exceptions.DefaultCredentialsError(_REFRESH_ERROR_MSG + self._principal) From 5082278c64e4559194ec59c78a8d077a9ec11262 Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Sat, 13 Oct 2018 17:37:08 -0700 Subject: [PATCH 05/15] remove extra refresh() --- google/auth/delegate_credentials.py | 1 - 1 file changed, 1 deletion(-) diff --git a/google/auth/delegate_credentials.py b/google/auth/delegate_credentials.py index 66e3caa9a..750675ae7 100644 --- a/google/auth/delegate_credentials.py +++ b/google/auth/delegate_credentials.py @@ -155,7 +155,6 @@ def _updateToken(self, req): `iamcredentials.googleapis.com` is not enabled or the `Service Account Token Creator` is not assigned """ - self._root_credentials.refresh(req) body = { "delegates": self._delegates, From 2d835970bc531af914917443dc022b611c8f008f Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Sun, 14 Oct 2018 22:39:20 -0700 Subject: [PATCH 06/15] Disallow refresh w/ lifetime set --- docs/user-guide.rst | 7 +- google/auth/delegate_credentials.py | 71 ++++++++------- tests/test_delegate_credentials.py | 128 +++++++++++++++++++++++----- 3 files changed, 145 insertions(+), 61 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 91c2ab435..8da0a7de3 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -219,14 +219,11 @@ principal the "Service Account Token Creator" IAM role:: root_credentials, project = google.auth.default(scopes=scopes) client = storage.Client(credentials=root_credentials) - buckets = client.list_buckets(project='your_project') - for bkt in buckets: - print bkt - new_scopes = scopes + delegate_credentials = DelegateCredentials( root_credentials = root_credentials, principal='impersonated-account@_project_.iam.gserviceaccount.com', - new_scopes = new_scopes, + new_scopes = scopes, delegates=[], lifetime=500) client = storage.Client(credentials=delegate_credentials) diff --git a/google/auth/delegate_credentials.py b/google/auth/delegate_credentials.py index 750675ae7..088163832 100644 --- a/google/auth/delegate_credentials.py +++ b/google/auth/delegate_credentials.py @@ -18,7 +18,7 @@ impersonates a remote service account using `IAM Credentials API`_. This class can be used to impersonate a service account as long as the original -Credential object as the "Service Account Token Creator" role on the target +Credential object has the "Service Account Token Creator" role on the target service account. .. _IAM Credentials API: @@ -26,73 +26,66 @@ """ import copy -from datetime import datetime, timedelta +from datetime import datetime import json from google.auth import _helpers from google.auth import credentials from google.auth import exceptions - _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds _IAM_SCOPE = ['https://www.googleapis.com/auth/iam'] -# Number of seconds before token expiration to initiate a refresh -_CLOCK_SKEW_SECS = 30 -_CLOCK_SKEW = timedelta(seconds=_CLOCK_SKEW_SECS) - _IAM_ENDPOINT = ('https://iamcredentials.googleapis.com/v1/projects/-' + '/serviceAccounts/{}:generateAccessToken') -_REFRESH_ERROR_MSG = 'Unable to acquire delegated credentials ' +_REFRESH_ERROR = 'Unable to acquire delegated credentials ' +_LIFETIME_ERROR = 'Delegate Credentials with lifetime set cannot be renewed' class DelegateCredentials(credentials.Credentials): """This module defines delegate credentials which are essentially impersonated identities. - When the `Service Account Token Creator_` IAM role is granted to a - service account, any other identity that has that capability can - impersonate that service account. + Delegate Credentials allows credentials issued to a user or + service account to impersonate another. The target service account must + grant the orginating credential principal the + `Service Account Token Creator`_ IAM role: + For more information about Token Creator IAM role and - IAMCredentials API, see `IAM documentation`. + IAMCredentials API, see + `Creating Short-Lived Service Account Credentials`_. .. _Service Account Token Creator: https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role + .. _Creating Short-Lived Service Account Credentials: + https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials + Usage: + First grant root_credentials the `Service Account Token Creator` role on the account to impersonate. In this example, the service account represented by svc_account.json has the token creator role on `impersonated-account@_project_.iam.gserviceaccount.com`. - Second, enable `iamcredentials.googleapis.com` API on the project - represented by `svc_account.json` - - - First initialze a root credential which does not have access to + Initialze a root credential which does not have access to list bucket:: os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'svc_account.json' scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] root_credentials, project = google.auth.default(scopes=scopes) - client = storage.Client(credentials=root_credentials) - buckets = client.list_buckets(project='your_project') - for bkt in buckets: - print bkt - Now use the root credentials to acquire credentials to impersonate - another user:: + another service account:: - new_scopes = scopes delegate_credentials = DelegateCredentials( root_credentials = root_credentials, principal='impersonated-account@_project_.iam.gserviceaccount.com', - new_scopes = new_scopes, + new_scopes = scopes, delegates=[], lifetime=500) @@ -106,7 +99,7 @@ class DelegateCredentials(credentials.Credentials): def __init__(self, root_credentials, principal, new_scopes, delegates=None, - lifetime=_DEFAULT_TOKEN_LIFETIME_SECS): + lifetime=None): """ Args: root_credentials (google.auth.Credentials): The root credential @@ -117,7 +110,9 @@ def __init__(self, root_credentials, principal, delegates (Sequence[str]): The chained list of delegates required to grant the final access_token. lifetime (int): Number of seconds the delegated credential should - be valid for (max 3600). + be valid for (upto 3600). If set, the credentials will + **not** get refreshed after expiration. If not set, the + credentials will be refreshed every 3600s. """ super(credentials.Credentials, self).__init__() @@ -133,13 +128,15 @@ def __init__(self, root_credentials, principal, @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): + if (self.token is not None and self._lifetime is not None): + self.expiry = _helpers.utcnow() + raise exceptions.RefreshError(_LIFETIME_ERROR) self._root_credentials.refresh(request) self._updateToken(request) @property def expired(self): - skewed_expiry = self.expiry - _CLOCK_SKEW - return _helpers.utcnow() >= skewed_expiry + return _helpers.utcnow() >= self.expiry def _updateToken(self, req): """Updates the delegate credentials with a new access_token representing @@ -150,16 +147,21 @@ def _updateToken(self, req): for refreshing credentials. Raises: + TransportError: Raised if there is an underlying HTTP connection + Error DefaultCredentialsError: Raised if the delegated credentials are not available. Common reasons are `iamcredentials.googleapis.com` is not enabled or the `Service Account Token Creator` is not assigned """ + lifetime = self._lifetime + if (self._lifetime is None): + lifetime = _DEFAULT_TOKEN_LIFETIME_SECS body = { "delegates": self._delegates, "scope": self._new_scopes, - "lifetime": str(self._lifetime) + "s" + "lifetime": str(lifetime) + "s" } iam_endpoint = _IAM_ENDPOINT.format(self._principal) @@ -178,8 +180,11 @@ def _updateToken(self, req): self.expiry = datetime.strptime(token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') else: - raise exceptions.DefaultCredentialsError(_REFRESH_ERROR_MSG + + raise exceptions.DefaultCredentialsError(_REFRESH_ERROR + self._principal) - except (exceptions.TransportError, ValueError, KeyError, TypeError): - raise exceptions.DefaultCredentialsError(_REFRESH_ERROR_MSG + + except (ValueError, KeyError, TypeError): + raise exceptions.DefaultCredentialsError(_REFRESH_ERROR + self._principal) + except (exceptions.TransportError): + raise exceptions.TransportError(_REFRESH_ERROR + + self._principal) diff --git a/tests/test_delegate_credentials.py b/tests/test_delegate_credentials.py index c6781e5b5..bd90aadc0 100644 --- a/tests/test_delegate_credentials.py +++ b/tests/test_delegate_credentials.py @@ -22,6 +22,7 @@ from google.auth import _helpers from google.auth import crypt +from google.auth import delegate_credentials from google.auth import exceptions from google.auth import transport from google.auth.delegate_credentials import DelegateCredentials @@ -38,6 +39,7 @@ SERVICE_ACCOUNT_INFO = json.load(fh) SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1') +TOKEN_URI = 'https://example.com/oauth2/token' class TestDelegateCredentials(object): @@ -46,23 +48,17 @@ class TestDelegateCredentials(object): IMPERSONATED_ACCOUNT = 'impersonated@project.iam.gserviceaccount.com' NEW_SCOPES = ['https://www.googleapis.com/auth/devstorage.read_only'] DELEGATES = [] - NEW_SCOPES = 3600 - - TOKEN_URI = 'https://example.com/oauth2/token' - - @classmethod - def make_credentials(cls): - root_credentials = service_account.Credentials( - SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI) - return DelegateCredentials( - root_credentials=root_credentials, - principal=cls.IMPERSONATED_ACCOUNT, - new_scopes=cls.NEW_SCOPES, - delegates=cls.DELEGATES, - lifetime=cls.NEW_SCOPES) + LIFETIME = 3600 + ROOT_CREDENTIALS = service_account.Credentials( + SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI) def test_default_state(self): - credentials = self.make_credentials() + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=self.LIFETIME) assert not credentials.valid assert credentials.expired @@ -81,7 +77,12 @@ def make_request(self, data, status=http_client.OK, @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): - credentials = self.make_credentials() + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -92,7 +93,7 @@ def test_refresh_success(self, jwt_grant): response_body = { "accessToken": token, "expireTime": (_helpers.utcnow().replace(microsecond=0) + - datetime.timedelta(seconds=credentials._lifetime) + datetime.timedelta(seconds=500) ).isoformat('T') + 'Z' } @@ -103,9 +104,80 @@ def test_refresh_success(self, jwt_grant): assert credentials.valid assert not credentials.expired + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) + def test_refresh_failure_malformed_expireTime(self, jwt_grant): + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=None) + token = 'token' + + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}) + + response_body = { + "accessToken": token, + "expireTime": (_helpers.utcnow() + + datetime.timedelta(seconds=500) + ).isoformat('T') + } + + request = self.make_request(data=json.dumps(response_body), + status=http_client.OK) + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(delegate_credentials._REFRESH_ERROR) + + assert not credentials.valid + assert credentials.expired + + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) + def test_refresh_failure_lifetime_specified(self, jwt_grant): + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=500) + token = 'token' + + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}) + + response_body = { + "accessToken": token, + "expireTime": (_helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=500) + ).isoformat('T') + 'Z' + } + + request = self.make_request(data=json.dumps(response_body), + status=http_client.OK) + + credentials.refresh(request) + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(request) + + assert excinfo.match(delegate_credentials._LIFETIME_ERROR) + + assert not credentials.valid + assert credentials.expired + @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_unauthorzed(self, jwt_grant): - credentials = self.make_credentials() + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -127,14 +199,19 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: credentials.refresh(request) - assert excinfo.match(r'Unable to acquire delegated credentials') + assert excinfo.match(delegate_credentials._REFRESH_ERROR) assert not credentials.valid assert credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_http_error(self, jwt_grant): - credentials = self.make_credentials() + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -148,14 +225,19 @@ def test_refresh_failure_http_error(self, jwt_grant): status=http_client.HTTPException, side_effect=exceptions.TransportError) - with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + with pytest.raises(exceptions.TransportError) as excinfo: credentials.refresh(request) - assert excinfo.match(r'Unable to acquire delegated credentials') + assert excinfo.match(delegate_credentials._REFRESH_ERROR) assert not credentials.valid assert credentials.expired def test_expired(self): - credentials = self.make_credentials() + credentials = DelegateCredentials( + root_credentials=self.ROOT_CREDENTIALS, + principal=self.IMPERSONATED_ACCOUNT, + new_scopes=self.NEW_SCOPES, + delegates=self.DELEGATES, + lifetime=None) assert credentials.expired From 212a75a634557bae4da4bb774f6ddf714625749f Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Fri, 2 Nov 2018 13:23:09 -0400 Subject: [PATCH 07/15] DelegateCredentials->ImpersonatedCredentials --- docs/index.rst | 2 +- .../google.auth.delegate_credentials.rst | 7 ----- .../google.auth.impersonated_credentials.rst | 7 +++++ docs/reference/google.auth.rst | 2 +- docs/user-guide.rst | 14 +++++----- ...entials.py => impersonated_credentials.py} | 24 ++++++++-------- ...ls.py => test_impersonated_credentials.py} | 28 +++++++++---------- 7 files changed, 42 insertions(+), 42 deletions(-) delete mode 100644 docs/reference/google.auth.delegate_credentials.rst create mode 100644 docs/reference/google.auth.impersonated_credentials.rst rename google/auth/{delegate_credentials.py => impersonated_credentials.py} (90%) rename tests/{test_delegate_credentials.py => test_impersonated_credentials.py} (91%) diff --git a/docs/index.rst b/docs/index.rst index 21e4feaad..1eb3d861a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ also provides integration with several HTTP libraries. - Support for signing and verifying :mod:`JWTs `. - Support for verifying and decoding :mod:`ID Tokens `. - Support for Google :mod:`Service Account credentials `. -- Support for Google :mod:`Delegate Credentials `. +- Support for Google :mod:`Impersonated Credentials `. - Support for :mod:`Google Compute Engine credentials `. - Support for :mod:`Google App Engine standard credentials `. - Support for various transports, including diff --git a/docs/reference/google.auth.delegate_credentials.rst b/docs/reference/google.auth.delegate_credentials.rst deleted file mode 100644 index 1cb2d2a61..000000000 --- a/docs/reference/google.auth.delegate_credentials.rst +++ /dev/null @@ -1,7 +0,0 @@ -google.auth.delegate\_credentials module -======================================== - -.. automodule:: google.auth.delegate_credentials - :members: - :inherited-members: - :show-inheritance: diff --git a/docs/reference/google.auth.impersonated_credentials.rst b/docs/reference/google.auth.impersonated_credentials.rst new file mode 100644 index 000000000..653708ef7 --- /dev/null +++ b/docs/reference/google.auth.impersonated_credentials.rst @@ -0,0 +1,7 @@ +google.auth.impersonated\_credentials module +============================================ + +.. automodule:: google.auth.impersonated_credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.rst b/docs/reference/google.auth.rst index 3242f21b5..bc6740b09 100644 --- a/docs/reference/google.auth.rst +++ b/docs/reference/google.auth.rst @@ -22,9 +22,9 @@ Submodules google.auth.app_engine google.auth.credentials google.auth.crypt - google.auth.delegate_credentials google.auth.environment_vars google.auth.exceptions google.auth.iam + google.auth.impersonated_credentials google.auth.jwt diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 8da0a7de3..1b168ce8c 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -205,14 +205,14 @@ You can also use :class:`google_auth_oauthlib.flow.Flow` to perform the OAuth .. _requests-oauthlib: https://requests-oauthlib.readthedocs.io/en/latest/ -Delegate credentials -++++++++++++++++++++ +Impersonated credentials +++++++++++++++++++++++++ -Delegate Credentials allows one set of credentials issued to a user or service account +Impersonated Credentials allows one set of credentials issued to a user or service account to impersonate another. The target service account must grant the orginating credential principal the "Service Account Token Creator" IAM role:: - from google.auth.delegate_credentials import DelegateCredentials + from google.auth.impersonated_credentials import ImpersonatedCredentials os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/svc_account.json' scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] @@ -220,20 +220,20 @@ principal the "Service Account Token Creator" IAM role:: root_credentials, project = google.auth.default(scopes=scopes) client = storage.Client(credentials=root_credentials) - delegate_credentials = DelegateCredentials( + impersonated_credentials = ImpersonatedCredentials( root_credentials = root_credentials, principal='impersonated-account@_project_.iam.gserviceaccount.com', new_scopes = scopes, delegates=[], lifetime=500) - client = storage.Client(credentials=delegate_credentials) + client = storage.Client(credentials=impersonated_credentials) buckets = client.list_buckets(project='your_project') for bkt in buckets: print bkt.name In the example above `root_credentials` does not have direct access to list buckets -in the target project. Using `Delegate Credentials` will allow the root_credentials +in the target project. Using `ImpersonatedCredentials` will allow the root_credentials to assume the identity of a principal that does have access Making authenticated requests diff --git a/google/auth/delegate_credentials.py b/google/auth/impersonated_credentials.py similarity index 90% rename from google/auth/delegate_credentials.py rename to google/auth/impersonated_credentials.py index 088163832..1b44a1925 100644 --- a/google/auth/delegate_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Google Cloud Delegated credentials. +"""Google Cloud Impersonated credentials. This module provides authentication for applications where local credentials impersonates a remote service account using `IAM Credentials API`_. @@ -40,15 +40,15 @@ _IAM_ENDPOINT = ('https://iamcredentials.googleapis.com/v1/projects/-' + '/serviceAccounts/{}:generateAccessToken') -_REFRESH_ERROR = 'Unable to acquire delegated credentials ' -_LIFETIME_ERROR = 'Delegate Credentials with lifetime set cannot be renewed' +_REFRESH_ERROR = 'Unable to acquire impersonated credentials ' +_LIFETIME_ERROR = 'Credentials with lifetime set cannot be renewed' -class DelegateCredentials(credentials.Credentials): - """This module defines delegate credentials which are essentially +class ImpersonatedCredentials(credentials.Credentials): + """This module defines impersonated credentials which are essentially impersonated identities. - Delegate Credentials allows credentials issued to a user or + Impersonated Credentials allows credentials issued to a user or service account to impersonate another. The target service account must grant the orginating credential principal the `Service Account Token Creator`_ IAM role: @@ -82,7 +82,7 @@ class DelegateCredentials(credentials.Credentials): Now use the root credentials to acquire credentials to impersonate another service account:: - delegate_credentials = DelegateCredentials( + impersonated_credentials = ImpersonatedCredentials( root_credentials = root_credentials, principal='impersonated-account@_project_.iam.gserviceaccount.com', new_scopes = scopes, @@ -91,7 +91,7 @@ class DelegateCredentials(credentials.Credentials): Resource access is granted:: - client = storage.Client(credentials=delegate_credentials) + client = storage.Client(credentials=impersonated_credentials) buckets = client.list_buckets(project='your_project') for bkt in buckets: print bkt.name @@ -103,7 +103,7 @@ def __init__(self, root_credentials, principal, """ Args: root_credentials (google.auth.Credentials): The root credential - used as to acquire the delegated credentials. + used as to acquire the impersonated credentials. principal (str): The service account to impersonatge. new_scopes (Sequence[str]): Scopes to request during the authorization grant. @@ -139,8 +139,8 @@ def expired(self): return _helpers.utcnow() >= self.expiry def _updateToken(self, req): - """Updates the delegate credentials with a new access_token representing - the delegated account. + """Updates credentials with a new access_token representing + the impersonated account. Args: req (google.auth.transport.requests.Request): Request object to use @@ -149,7 +149,7 @@ def _updateToken(self, req): Raises: TransportError: Raised if there is an underlying HTTP connection Error - DefaultCredentialsError: Raised if the delegated credentials + DefaultCredentialsError: Raised if the impersonated credentials are not available. Common reasons are `iamcredentials.googleapis.com` is not enabled or the `Service Account Token Creator` is not assigned diff --git a/tests/test_delegate_credentials.py b/tests/test_impersonated_credentials.py similarity index 91% rename from tests/test_delegate_credentials.py rename to tests/test_impersonated_credentials.py index bd90aadc0..93806e498 100644 --- a/tests/test_delegate_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -22,10 +22,10 @@ from google.auth import _helpers from google.auth import crypt -from google.auth import delegate_credentials from google.auth import exceptions +from google.auth import impersonated_credentials from google.auth import transport -from google.auth.delegate_credentials import DelegateCredentials +from google.auth.impersonated_credentials import ImpersonatedCredentials from google.oauth2 import service_account DATA_DIR = os.path.join(os.path.dirname(__file__), '', 'data') @@ -42,7 +42,7 @@ TOKEN_URI = 'https://example.com/oauth2/token' -class TestDelegateCredentials(object): +class TestImpersonatedCredentials(object): SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' IMPERSONATED_ACCOUNT = 'impersonated@project.iam.gserviceaccount.com' @@ -53,7 +53,7 @@ class TestDelegateCredentials(object): SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI) def test_default_state(self): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, @@ -77,7 +77,7 @@ def make_request(self, data, status=http_client.OK, @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, @@ -106,7 +106,7 @@ def test_refresh_success(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_malformed_expireTime(self, jwt_grant): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, @@ -131,14 +131,14 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: credentials.refresh(request) - assert excinfo.match(delegate_credentials._REFRESH_ERROR) + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) assert not credentials.valid assert credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_lifetime_specified(self, jwt_grant): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, @@ -165,14 +165,14 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): with pytest.raises(exceptions.RefreshError) as excinfo: credentials.refresh(request) - assert excinfo.match(delegate_credentials._LIFETIME_ERROR) + assert excinfo.match(impersonated_credentials._LIFETIME_ERROR) assert not credentials.valid assert credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_unauthorzed(self, jwt_grant): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, @@ -199,14 +199,14 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: credentials.refresh(request) - assert excinfo.match(delegate_credentials._REFRESH_ERROR) + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) assert not credentials.valid assert credentials.expired @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_http_error(self, jwt_grant): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, @@ -228,13 +228,13 @@ def test_refresh_failure_http_error(self, jwt_grant): with pytest.raises(exceptions.TransportError) as excinfo: credentials.refresh(request) - assert excinfo.match(delegate_credentials._REFRESH_ERROR) + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) assert not credentials.valid assert credentials.expired def test_expired(self): - credentials = DelegateCredentials( + credentials = ImpersonatedCredentials( root_credentials=self.ROOT_CREDENTIALS, principal=self.IMPERSONATED_ACCOUNT, new_scopes=self.NEW_SCOPES, From 1e15ff1785bba9c1eaecfc716e75691e3422339c Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Wed, 7 Nov 2018 17:15:29 -0800 Subject: [PATCH 08/15] change parameter name --- docs/user-guide.rst | 33 +++---- google/auth/impersonated_credentials.py | 125 ++++++++++++++---------- tests/test_impersonated_credentials.py | 48 ++++----- 3 files changed, 111 insertions(+), 95 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 1b168ce8c..f6d91a9f8 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -209,32 +209,31 @@ Impersonated credentials ++++++++++++++++++++++++ Impersonated Credentials allows one set of credentials issued to a user or service account -to impersonate another. The target service account must grant the orginating credential -principal the "Service Account Token Creator" IAM role:: +to impersonate another. The target service account must grant the source credential +the "Service Account Token Creator" IAM role:: from google.auth.impersonated_credentials import ImpersonatedCredentials - os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/svc_account.json' - scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] - - root_credentials, project = google.auth.default(scopes=scopes) - client = storage.Client(credentials=root_credentials) + target_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + source_credentials = service_account.Credentials.from_service_account_file( + '/path/to/svc_account.json', + scopes=target_scopes) - impersonated_credentials = ImpersonatedCredentials( - root_credentials = root_credentials, - principal='impersonated-account@_project_.iam.gserviceaccount.com', - new_scopes = scopes, + target_credentials = ImpersonatedCredentials( + source_credentials=source_credentials, + target_principal='impersonated-account@_project_.iam.gserviceaccount.com', + target_scopes=target_scopes, delegates=[], lifetime=500) - client = storage.Client(credentials=impersonated_credentials) + client = storage.Client(credentials = target_credentials) buckets = client.list_buckets(project='your_project') - for bkt in buckets: - print bkt.name + for bucket in buckets: + print bucket.name -In the example above `root_credentials` does not have direct access to list buckets -in the target project. Using `ImpersonatedCredentials` will allow the root_credentials -to assume the identity of a principal that does have access +In the example above `source_credentials` does not have direct access to list buckets +in the target project. Using `ImpersonatedCredentials` will allow the source_credentials +to assume the identity of a target_principal that does have access Making authenticated requests ----------------------------- diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 1b44a1925..861504c52 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -65,50 +65,58 @@ class ImpersonatedCredentials(credentials.Credentials): Usage: - First grant root_credentials the `Service Account Token Creator` - role on the account to impersonate. In this example, the + First grant source_credentials the `Service Account Token Creator` + role on the target account to impersonate. In this example, the service account represented by svc_account.json has the token creator role on `impersonated-account@_project_.iam.gserviceaccount.com`. - Initialze a root credential which does not have access to + Initialze a source credential which does not have access to list bucket:: - os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'svc_account.json' - scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + target_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] + source_credentials = service_account.Credentials.from_service_account_file( + '/path/to/svc_account.json', + scopes=target_scopes) - root_credentials, project = google.auth.default(scopes=scopes) - - Now use the root credentials to acquire credentials to impersonate + Now use the source credentials to acquire credentials to impersonate another service account:: - impersonated_credentials = ImpersonatedCredentials( - root_credentials = root_credentials, - principal='impersonated-account@_project_.iam.gserviceaccount.com', - new_scopes = scopes, + target_credentials = ImpersonatedCredentials( + source_credentials = source_credentials, + target_principal='impersonated-account@_project_.iam.gserviceaccount.com', + target_scopes = target_scopes, delegates=[], lifetime=500) Resource access is granted:: - client = storage.Client(credentials=impersonated_credentials) + client = storage.Client(credentials=target_credentials) buckets = client.list_buckets(project='your_project') - for bkt in buckets: - print bkt.name + for bucket in buckets: + print bucket.name """ - def __init__(self, root_credentials, principal, - new_scopes, delegates=None, + def __init__(self, source_credentials, target_principal, + target_scopes, delegates=None, lifetime=None): """ Args: - root_credentials (google.auth.Credentials): The root credential + source_credentials (google.auth.Credentials): The source credential used as to acquire the impersonated credentials. - principal (str): The service account to impersonatge. - new_scopes (Sequence[str]): Scopes to request during the + target_principal (str): The service account to impersonate. + target_scopes (Sequence[str]): Scopes to request during the authorization grant. delegates (Sequence[str]): The chained list of delegates required - to grant the final access_token. + to grant the final access_token. If set, the sequence of + identities must have "Service Account Token Creator" capability + granted to the prceeding identity. For example, if set to + [serviceAccountB, serviceAccountC], the source_credential + must have the Token Creator role on serviceAccountB. + serviceAccountB must have the Token Creator on serviceAccountC. + Finally, C must have Token Creator on target_principal. + If left unset, source_credential must have that role on + target_principal. lifetime (int): Number of seconds the delegated credential should be valid for (upto 3600). If set, the credentials will **not** get refreshed after expiration. If not set, the @@ -117,10 +125,10 @@ def __init__(self, root_credentials, principal, super(credentials.Credentials, self).__init__() - self._root_credentials = copy.copy(root_credentials) - self._root_credentials._scopes = _IAM_SCOPE - self._principal = principal - self._new_scopes = new_scopes + self._source_credentials = copy.copy(source_credentials) + self._source_credentials._scopes = _IAM_SCOPE + self._target_principal = target_principal + self._target_scopes = target_scopes self._delegates = delegates self._lifetime = lifetime self.token = None @@ -131,46 +139,30 @@ def refresh(self, request): if (self.token is not None and self._lifetime is not None): self.expiry = _helpers.utcnow() raise exceptions.RefreshError(_LIFETIME_ERROR) - self._root_credentials.refresh(request) - self._updateToken(request) + self._source_credentials.refresh(request) + self._update_token(request) @property def expired(self): return _helpers.utcnow() >= self.expiry - def _updateToken(self, req): - """Updates credentials with a new access_token representing - the impersonated account. - + def _make_iam_token_request(self, request, headers, body): + """ Args: - req (google.auth.transport.requests.Request): Request object to use - for refreshing credentials. - + headers (Mapping[str, str]): Map of headers to transmit. + body (Mapping[str, str]): JSON Payload body for the iamcredentials + API call. Raises: TransportError: Raised if there is an underlying HTTP connection Error DefaultCredentialsError: Raised if the impersonated credentials are not available. Common reasons are `iamcredentials.googleapis.com` is not enabled or the - `Service Account Token Creator` is not assigned + `Service Account Token Creator` is not assigned """ - - lifetime = self._lifetime - if (self._lifetime is None): - lifetime = _DEFAULT_TOKEN_LIFETIME_SECS - body = { - "delegates": self._delegates, - "scope": self._new_scopes, - "lifetime": str(lifetime) + "s" - } - - iam_endpoint = _IAM_ENDPOINT.format(self._principal) + iam_endpoint = _IAM_ENDPOINT.format(self._target_principal) try: - headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + self._root_credentials.token - } - response = req(url=iam_endpoint, + response = request(url=iam_endpoint, method='POST', headers=headers, json=body) @@ -181,10 +173,35 @@ def _updateToken(self, req): '%Y-%m-%dT%H:%M:%SZ') else: raise exceptions.DefaultCredentialsError(_REFRESH_ERROR + - self._principal) + self._target_principal) except (ValueError, KeyError, TypeError): raise exceptions.DefaultCredentialsError(_REFRESH_ERROR + - self._principal) + self._target_principal) except (exceptions.TransportError): raise exceptions.TransportError(_REFRESH_ERROR + - self._principal) + self._target_principal) + + def _update_token(self, request): + """Updates credentials with a new access_token representing + the impersonated account. + + Args: + request (google.auth.transport.requests.Request): Request object to use + for refreshing credentials. + """ + + lifetime = self._lifetime + if (self._lifetime is None): + lifetime = _DEFAULT_TOKEN_LIFETIME_SECS + body = { + "delegates": self._delegates, + "scope": self._target_scopes, + "lifetime": str(lifetime) + "s" + } + + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + self._source_credentials.token + } + self._make_iam_token_request(request=request, + headers=headers, body=body) diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 93806e498..bd60746fc 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -45,18 +45,18 @@ class TestImpersonatedCredentials(object): SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' - IMPERSONATED_ACCOUNT = 'impersonated@project.iam.gserviceaccount.com' - NEW_SCOPES = ['https://www.googleapis.com/auth/devstorage.read_only'] + TARGET_PRINCIPAL = 'impersonated@project.iam.gserviceaccount.com' + TARGET_SCOPES = ['https://www.googleapis.com/auth/devstorage.read_only'] DELEGATES = [] LIFETIME = 3600 - ROOT_CREDENTIALS = service_account.Credentials( + SOURCE_CREDENTIALS = service_account.Credentials( SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI) def test_default_state(self): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=self.LIFETIME) assert not credentials.valid @@ -78,9 +78,9 @@ def make_request(self, data, status=http_client.OK, @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=None) token = 'token' @@ -107,9 +107,9 @@ def test_refresh_success(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_malformed_expireTime(self, jwt_grant): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=None) token = 'token' @@ -139,9 +139,9 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_lifetime_specified(self, jwt_grant): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=500) token = 'token' @@ -173,9 +173,9 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_unauthorzed(self, jwt_grant): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=None) token = 'token' @@ -207,9 +207,9 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_http_error(self, jwt_grant): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=None) token = 'token' @@ -235,9 +235,9 @@ def test_refresh_failure_http_error(self, jwt_grant): def test_expired(self): credentials = ImpersonatedCredentials( - root_credentials=self.ROOT_CREDENTIALS, - principal=self.IMPERSONATED_ACCOUNT, - new_scopes=self.NEW_SCOPES, + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=None) assert credentials.expired From 834d81665cd277714f824ac40bbd4a8a8b9caa5a Mon Sep 17 00:00:00 2001 From: salmaan rashid Date: Wed, 7 Nov 2018 17:30:08 -0800 Subject: [PATCH 09/15] reformat for linter --- google/auth/impersonated_credentials.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 861504c52..9aa940929 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -151,21 +151,21 @@ def _make_iam_token_request(self, request, headers, body): Args: headers (Mapping[str, str]): Map of headers to transmit. body (Mapping[str, str]): JSON Payload body for the iamcredentials - API call. + API call. Raises: TransportError: Raised if there is an underlying HTTP connection Error DefaultCredentialsError: Raised if the impersonated credentials are not available. Common reasons are `iamcredentials.googleapis.com` is not enabled or the - `Service Account Token Creator` is not assigned + `Service Account Token Creator` is not assigned """ iam_endpoint = _IAM_ENDPOINT.format(self._target_principal) try: response = request(url=iam_endpoint, - method='POST', - headers=headers, - json=body) + method='POST', + headers=headers, + json=body) if (response.status == 200): token_response = json.loads(response.data.decode('utf-8')) self.token = token_response['accessToken'] @@ -186,8 +186,8 @@ def _update_token(self, request): the impersonated account. Args: - request (google.auth.transport.requests.Request): Request object to use - for refreshing credentials. + request (google.auth.transport.requests.Request): Request object + to use for refreshing credentials. """ lifetime = self._lifetime @@ -204,4 +204,4 @@ def _update_token(self, request): 'Authorization': 'Bearer ' + self._source_credentials.token } self._make_iam_token_request(request=request, - headers=headers, body=body) + headers=headers, body=body) From c087768fc273efdfbb56e305b319cc4e29817e43 Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Thu, 8 Nov 2018 13:10:02 -0800 Subject: [PATCH 10/15] Fix lint --- google/auth/impersonated_credentials.py | 49 +++++++++++++++---------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 9aa940929..08430315e 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -74,15 +74,22 @@ class ImpersonatedCredentials(credentials.Credentials): Initialze a source credential which does not have access to list bucket:: - target_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] - source_credentials = service_account.Credentials.from_service_account_file( - '/path/to/svc_account.json', - scopes=target_scopes) + from google.oauth2 import service_acccount + + target_scopes = [ + 'https://www.googleapis.com/auth/devstorage.read_only'] + + source_credentials = ( + service_account.Credentials.from_service_account_file( + '/path/to/svc_account.json', + scopes=target_scopes)) Now use the source credentials to acquire credentials to impersonate another service account:: - target_credentials = ImpersonatedCredentials( + from google.auth import impersonated_credentials + + target_credentials = impersonated_credentials.ImpersonatedCredentials( source_credentials = source_credentials, target_principal='impersonated-account@_project_.iam.gserviceaccount.com', target_scopes = target_scopes, @@ -162,24 +169,27 @@ def _make_iam_token_request(self, request, headers, body): """ iam_endpoint = _IAM_ENDPOINT.format(self._target_principal) try: - response = request(url=iam_endpoint, - method='POST', - headers=headers, - json=body) + response = request( + url=iam_endpoint, + method='POST', + headers=headers, + json=body) + if (response.status == 200): token_response = json.loads(response.data.decode('utf-8')) self.token = token_response['accessToken'] - self.expiry = datetime.strptime(token_response['expireTime'], - '%Y-%m-%dT%H:%M:%SZ') + self.expiry = datetime.strptime( + token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') else: - raise exceptions.DefaultCredentialsError(_REFRESH_ERROR + - self._target_principal) + raise exceptions.DefaultCredentialsError( + _REFRESH_ERROR + self._target_principal) + except (ValueError, KeyError, TypeError): - raise exceptions.DefaultCredentialsError(_REFRESH_ERROR + - self._target_principal) + raise exceptions.DefaultCredentialsError( + _REFRESH_ERROR + self._target_principal) except (exceptions.TransportError): - raise exceptions.TransportError(_REFRESH_ERROR + - self._target_principal) + raise exceptions.TransportError( + _REFRESH_ERROR + self._target_principal) def _update_token(self, request): """Updates credentials with a new access_token representing @@ -193,6 +203,7 @@ def _update_token(self, request): lifetime = self._lifetime if (self._lifetime is None): lifetime = _DEFAULT_TOKEN_LIFETIME_SECS + body = { "delegates": self._delegates, "scope": self._target_scopes, @@ -203,5 +214,5 @@ def _update_token(self, request): 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self._source_credentials.token } - self._make_iam_token_request(request=request, - headers=headers, body=body) + self._make_iam_token_request( + request=request, headers=headers, body=body) From 5e2d8e74d5179fec929f64aa80e4677421217229 Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Thu, 8 Nov 2018 13:13:40 -0800 Subject: [PATCH 11/15] Rename class from ImpersonatedCredential to Credentials to match others --- google/auth/impersonated_credentials.py | 10 +-- tests/test_impersonated_credentials.py | 86 ++++++++++++------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 08430315e..343f8ed7a 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -44,13 +44,13 @@ _LIFETIME_ERROR = 'Credentials with lifetime set cannot be renewed' -class ImpersonatedCredentials(credentials.Credentials): +class Credentials(credentials.Credentials): """This module defines impersonated credentials which are essentially impersonated identities. Impersonated Credentials allows credentials issued to a user or service account to impersonate another. The target service account must - grant the orginating credential principal the + grant the originating credential principal the `Service Account Token Creator`_ IAM role: For more information about Token Creator IAM role and @@ -71,7 +71,7 @@ class ImpersonatedCredentials(credentials.Credentials): token creator role on `impersonated-account@_project_.iam.gserviceaccount.com`. - Initialze a source credential which does not have access to + Initialize a source credential which does not have access to list bucket:: from google.oauth2 import service_acccount @@ -89,7 +89,7 @@ class ImpersonatedCredentials(credentials.Credentials): from google.auth import impersonated_credentials - target_credentials = impersonated_credentials.ImpersonatedCredentials( + target_credentials = impersonated_credentials.Credentials( source_credentials = source_credentials, target_principal='impersonated-account@_project_.iam.gserviceaccount.com', target_scopes = target_scopes, @@ -130,7 +130,7 @@ def __init__(self, source_credentials, target_principal, credentials will be refreshed every 3600s. """ - super(credentials.Credentials, self).__init__() + super(Credentials, self).__init__() self._source_credentials = copy.copy(source_credentials) self._source_credentials._scopes = _IAM_SCOPE diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index bd60746fc..11fde1c11 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -25,7 +25,7 @@ from google.auth import exceptions from google.auth import impersonated_credentials from google.auth import transport -from google.auth.impersonated_credentials import ImpersonatedCredentials +from google.auth.impersonated_credentials import Credentials from google.oauth2 import service_account DATA_DIR = os.path.join(os.path.dirname(__file__), '', 'data') @@ -53,12 +53,12 @@ class TestImpersonatedCredentials(object): SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI) def test_default_state(self): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=self.LIFETIME) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=self.LIFETIME) assert not credentials.valid assert credentials.expired @@ -77,12 +77,12 @@ def make_request(self, data, status=http_client.OK, @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -106,12 +106,12 @@ def test_refresh_success(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_malformed_expireTime(self, jwt_grant): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -138,12 +138,12 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_lifetime_specified(self, jwt_grant): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=500) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=500) token = 'token' jwt_grant.return_value = ( @@ -172,12 +172,12 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_unauthorzed(self, jwt_grant): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -206,12 +206,12 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_http_error(self, jwt_grant): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -234,10 +234,10 @@ def test_refresh_failure_http_error(self, jwt_grant): assert credentials.expired def test_expired(self): - credentials = ImpersonatedCredentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = Credentials( + source_credentials=self.SOURCE_CREDENTIALS, + target_principal=self.TARGET_PRINCIPAL, + target_scopes=self.TARGET_SCOPES, + delegates=self.DELEGATES, + lifetime=None) assert credentials.expired From d30e9ac002505263ccb0790fd3f3c3f051249229 Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Thu, 8 Nov 2018 13:18:06 -0800 Subject: [PATCH 12/15] Cleanup tests --- tests/test_impersonated_credentials.py | 99 +++++++++++--------------- 1 file changed, 40 insertions(+), 59 deletions(-) diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 11fde1c11..fd15381e5 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -52,13 +52,16 @@ class TestImpersonatedCredentials(object): SOURCE_CREDENTIALS = service_account.Credentials( SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI) - def test_default_state(self): - credentials = Credentials( + def make_credentials(self, lifetime=LIFETIME): + return Credentials( source_credentials=self.SOURCE_CREDENTIALS, target_principal=self.TARGET_PRINCIPAL, target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, - lifetime=self.LIFETIME) + lifetime=lifetime) + + def test_default_state(self): + credentials = self.make_credentials() assert not credentials.valid assert credentials.expired @@ -77,12 +80,7 @@ def make_request(self, data, status=http_client.OK, @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_success(self, jwt_grant): - credentials = Credentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = self.make_credentials(lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -90,15 +88,17 @@ def test_refresh_success(self, jwt_grant): _helpers.utcnow() + datetime.timedelta(seconds=500), {}) + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=500)).isoformat('T') + 'Z' response_body = { "accessToken": token, - "expireTime": (_helpers.utcnow().replace(microsecond=0) + - datetime.timedelta(seconds=500) - ).isoformat('T') + 'Z' + "expireTime": expire_time } - request = self.make_request(data=json.dumps(response_body), - status=http_client.OK) + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK) credentials.refresh(request) assert credentials.valid @@ -106,12 +106,7 @@ def test_refresh_success(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_malformed_expireTime(self, jwt_grant): - credentials = Credentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = self.make_credentials(lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -119,15 +114,17 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): _helpers.utcnow() + datetime.timedelta(seconds=500), {}) + expire_time = ( + _helpers.utcnow() + datetime.timedelta(seconds=500)).isoformat('T') response_body = { "accessToken": token, - "expireTime": (_helpers.utcnow() + - datetime.timedelta(seconds=500) - ).isoformat('T') + "expireTime": expire_time } - request = self.make_request(data=json.dumps(response_body), - status=http_client.OK) + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK) + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: credentials.refresh(request) @@ -138,12 +135,7 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_lifetime_specified(self, jwt_grant): - credentials = Credentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=500) + credentials = self.make_credentials(lifetime=500) token = 'token' jwt_grant.return_value = ( @@ -151,15 +143,17 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): _helpers.utcnow() + datetime.timedelta(seconds=500), {}) + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=500)).isoformat('T') + 'Z' response_body = { "accessToken": token, - "expireTime": (_helpers.utcnow().replace(microsecond=0) + - datetime.timedelta(seconds=500) - ).isoformat('T') + 'Z' + "expireTime": expire_time } - request = self.make_request(data=json.dumps(response_body), - status=http_client.OK) + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK) credentials.refresh(request) with pytest.raises(exceptions.RefreshError) as excinfo: @@ -172,12 +166,7 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_unauthorzed(self, jwt_grant): - credentials = Credentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = self.make_credentials(lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -193,8 +182,9 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): } } - request = self.make_request(data=json.dumps(response_body), - status=http_client.UNAUTHORIZED) + request = self.make_request( + data=json.dumps(response_body), + status=http_client.UNAUTHORIZED) with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: credentials.refresh(request) @@ -206,12 +196,7 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): @mock.patch('google.oauth2._client.jwt_grant', autospec=True) def test_refresh_failure_http_error(self, jwt_grant): - credentials = Credentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = self.make_credentials(lifetime=None) token = 'token' jwt_grant.return_value = ( @@ -221,9 +206,10 @@ def test_refresh_failure_http_error(self, jwt_grant): response_body = {} - request = self.make_request(data=json.dumps(response_body), - status=http_client.HTTPException, - side_effect=exceptions.TransportError) + request = self.make_request( + data=json.dumps(response_body), + status=http_client.HTTPException, + side_effect=exceptions.TransportError) with pytest.raises(exceptions.TransportError) as excinfo: credentials.refresh(request) @@ -234,10 +220,5 @@ def test_refresh_failure_http_error(self, jwt_grant): assert credentials.expired def test_expired(self): - credentials = Credentials( - source_credentials=self.SOURCE_CREDENTIALS, - target_principal=self.TARGET_PRINCIPAL, - target_scopes=self.TARGET_SCOPES, - delegates=self.DELEGATES, - lifetime=None) + credentials = self.make_credentials(lifetime=None) assert credentials.expired From 18aab273ef6b97890e0b646f54c3ef785a1f8542 Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Thu, 8 Nov 2018 13:18:52 -0800 Subject: [PATCH 13/15] Update docstrings to remove optional delegates parameter --- docs/user-guide.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index f6d91a9f8..0a4658e93 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -223,7 +223,6 @@ the "Service Account Token Creator" IAM role:: source_credentials=source_credentials, target_principal='impersonated-account@_project_.iam.gserviceaccount.com', target_scopes=target_scopes, - delegates=[], lifetime=500) client = storage.Client(credentials = target_credentials) buckets = client.list_buckets(project='your_project') From 5c385de0c538574c747798c29330e821f634ffaf Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Thu, 8 Nov 2018 13:24:10 -0800 Subject: [PATCH 14/15] Update docstrings --- docs/user-guide.rst | 8 ++++---- google/auth/impersonated_credentials.py | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 0a4658e93..758791795 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -212,19 +212,19 @@ Impersonated Credentials allows one set of credentials issued to a user or servi to impersonate another. The target service account must grant the source credential the "Service Account Token Creator" IAM role:: - from google.auth.impersonated_credentials import ImpersonatedCredentials + from google.auth import impersonated_credentials target_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] source_credentials = service_account.Credentials.from_service_account_file( '/path/to/svc_account.json', - scopes=target_scopes) + scopes=target_scopes) - target_credentials = ImpersonatedCredentials( + target_credentials = impersonated_credentials.Credentials( source_credentials=source_credentials, target_principal='impersonated-account@_project_.iam.gserviceaccount.com', target_scopes=target_scopes, lifetime=500) - client = storage.Client(credentials = target_credentials) + client = storage.Client(credentials=target_credentials) buckets = client.list_buckets(project='your_project') for bucket in buckets: print bucket.name diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 343f8ed7a..e0a1f9b4c 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -90,10 +90,9 @@ class Credentials(credentials.Credentials): from google.auth import impersonated_credentials target_credentials = impersonated_credentials.Credentials( - source_credentials = source_credentials, + source_credentials=source_credentials, target_principal='impersonated-account@_project_.iam.gserviceaccount.com', target_scopes = target_scopes, - delegates=[], lifetime=500) Resource access is granted:: From 02d744c35f6027343c8b6e261ccaf97aca5c49eb Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Thu, 8 Nov 2018 13:50:32 -0800 Subject: [PATCH 15/15] Refactor _make_iam_token_request into a regular function --- google/auth/impersonated_credentials.py | 108 ++++++++++++++---------- tests/test_impersonated_credentials.py | 64 +++++--------- 2 files changed, 87 insertions(+), 85 deletions(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index e0a1f9b4c..ca625b8d4 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -29,6 +29,9 @@ from datetime import datetime import json +import six +from six.moves import http_client + from google.auth import _helpers from google.auth import credentials from google.auth import exceptions @@ -40,10 +43,58 @@ _IAM_ENDPOINT = ('https://iamcredentials.googleapis.com/v1/projects/-' + '/serviceAccounts/{}:generateAccessToken') -_REFRESH_ERROR = 'Unable to acquire impersonated credentials ' +_REFRESH_ERROR = 'Unable to acquire impersonated credentials' _LIFETIME_ERROR = 'Credentials with lifetime set cannot be renewed' +def _make_iam_token_request(request, principal, headers, body): + """Makes a request to the Google Cloud IAM service for an access token. + Args: + request (Request): The Request object to use. + principal (str): The principal to request an access token for. + headers (Mapping[str, str]): Map of headers to transmit. + body (Mapping[str, str]): JSON Payload body for the iamcredentials + API call. + + Raises: + TransportError: Raised if there is an underlying HTTP connection + Error + DefaultCredentialsError: Raised if the impersonated credentials + are not available. Common reasons are + `iamcredentials.googleapis.com` is not enabled or the + `Service Account Token Creator` is not assigned + """ + iam_endpoint = _IAM_ENDPOINT.format(principal) + + body = json.dumps(body) + + response = request( + url=iam_endpoint, + method='POST', + headers=headers, + body=body) + + response_body = response.data.decode('utf-8') + + if response.status != http_client.OK: + exceptions.RefreshError(_REFRESH_ERROR, response_body) + + try: + token_response = json.loads(response.data.decode('utf-8')) + token = token_response['accessToken'] + expiry = datetime.strptime( + token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') + + return token, expiry + + except (KeyError, ValueError) as caught_exc: + new_exc = exceptions.RefreshError( + '{}: No access token or invalid expiration in response.'.format( + _REFRESH_ERROR), + response_body) + six.raise_from(new_exc, caught_exc) + + class Credentials(credentials.Credentials): """This module defines impersonated credentials which are essentially impersonated identities. @@ -152,44 +203,6 @@ def refresh(self, request): def expired(self): return _helpers.utcnow() >= self.expiry - def _make_iam_token_request(self, request, headers, body): - """ - Args: - headers (Mapping[str, str]): Map of headers to transmit. - body (Mapping[str, str]): JSON Payload body for the iamcredentials - API call. - Raises: - TransportError: Raised if there is an underlying HTTP connection - Error - DefaultCredentialsError: Raised if the impersonated credentials - are not available. Common reasons are - `iamcredentials.googleapis.com` is not enabled or the - `Service Account Token Creator` is not assigned - """ - iam_endpoint = _IAM_ENDPOINT.format(self._target_principal) - try: - response = request( - url=iam_endpoint, - method='POST', - headers=headers, - json=body) - - if (response.status == 200): - token_response = json.loads(response.data.decode('utf-8')) - self.token = token_response['accessToken'] - self.expiry = datetime.strptime( - token_response['expireTime'], '%Y-%m-%dT%H:%M:%SZ') - else: - raise exceptions.DefaultCredentialsError( - _REFRESH_ERROR + self._target_principal) - - except (ValueError, KeyError, TypeError): - raise exceptions.DefaultCredentialsError( - _REFRESH_ERROR + self._target_principal) - except (exceptions.TransportError): - raise exceptions.TransportError( - _REFRESH_ERROR + self._target_principal) - def _update_token(self, request): """Updates credentials with a new access_token representing the impersonated account. @@ -199,6 +212,9 @@ def _update_token(self, request): to use for refreshing credentials. """ + # Refresh our source credentials. + self._source_credentials.refresh(request) + lifetime = self._lifetime if (self._lifetime is None): lifetime = _DEFAULT_TOKEN_LIFETIME_SECS @@ -210,8 +226,14 @@ def _update_token(self, request): } headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + self._source_credentials.token + 'Content-Type': 'application/json', } - self._make_iam_token_request( - request=request, headers=headers, body=body) + + # Apply the source credentials authentication info. + self._source_credentials.apply(headers) + + self.token, self.expiry = _make_iam_token_request( + request=request, + principal=self._target_principal, + headers=headers, + body=body) diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index fd15381e5..74342ce03 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -42,6 +42,16 @@ TOKEN_URI = 'https://example.com/oauth2/token' +@pytest.fixture +def mock_donor_credentials(): + with mock.patch('google.oauth2._client.jwt_grant', autospec=True) as grant: + grant.return_value = ( + "source token", + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}) + yield grant + + class TestImpersonatedCredentials(object): SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' @@ -78,16 +88,10 @@ def make_request(self, data, status=http_client.OK, return request - @mock.patch('google.oauth2._client.jwt_grant', autospec=True) - def test_refresh_success(self, jwt_grant): + def test_refresh_success(self, mock_donor_credentials): credentials = self.make_credentials(lifetime=None) token = 'token' - jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}) - expire_time = ( _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500)).isoformat('T') + 'Z' @@ -99,21 +103,17 @@ def test_refresh_success(self, jwt_grant): request = self.make_request( data=json.dumps(response_body), status=http_client.OK) + credentials.refresh(request) assert credentials.valid assert not credentials.expired - @mock.patch('google.oauth2._client.jwt_grant', autospec=True) - def test_refresh_failure_malformed_expireTime(self, jwt_grant): + def test_refresh_failure_malformed_expire_time( + self, mock_donor_credentials): credentials = self.make_credentials(lifetime=None) token = 'token' - jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}) - expire_time = ( _helpers.utcnow() + datetime.timedelta(seconds=500)).isoformat('T') response_body = { @@ -125,7 +125,7 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): data=json.dumps(response_body), status=http_client.OK) - with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + with pytest.raises(exceptions.RefreshError) as excinfo: credentials.refresh(request) assert excinfo.match(impersonated_credentials._REFRESH_ERROR) @@ -133,16 +133,10 @@ def test_refresh_failure_malformed_expireTime(self, jwt_grant): assert not credentials.valid assert credentials.expired - @mock.patch('google.oauth2._client.jwt_grant', autospec=True) - def test_refresh_failure_lifetime_specified(self, jwt_grant): + def test_refresh_failure_lifetime_specified(self, mock_donor_credentials): credentials = self.make_credentials(lifetime=500) token = 'token' - jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}) - expire_time = ( _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=500)).isoformat('T') + 'Z' @@ -156,6 +150,7 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): status=http_client.OK) credentials.refresh(request) + with pytest.raises(exceptions.RefreshError) as excinfo: credentials.refresh(request) @@ -164,15 +159,8 @@ def test_refresh_failure_lifetime_specified(self, jwt_grant): assert not credentials.valid assert credentials.expired - @mock.patch('google.oauth2._client.jwt_grant', autospec=True) - def test_refresh_failure_unauthorzed(self, jwt_grant): + def test_refresh_failure_unauthorzed(self, mock_donor_credentials): credentials = self.make_credentials(lifetime=None) - token = 'token' - - jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}) response_body = { "error": { @@ -186,7 +174,7 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): data=json.dumps(response_body), status=http_client.UNAUTHORIZED) - with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + with pytest.raises(exceptions.RefreshError) as excinfo: credentials.refresh(request) assert excinfo.match(impersonated_credentials._REFRESH_ERROR) @@ -194,24 +182,16 @@ def test_refresh_failure_unauthorzed(self, jwt_grant): assert not credentials.valid assert credentials.expired - @mock.patch('google.oauth2._client.jwt_grant', autospec=True) - def test_refresh_failure_http_error(self, jwt_grant): + def test_refresh_failure_http_error(self, mock_donor_credentials): credentials = self.make_credentials(lifetime=None) - token = 'token' - - jwt_grant.return_value = ( - token, - _helpers.utcnow() + datetime.timedelta(seconds=500), - {}) response_body = {} request = self.make_request( data=json.dumps(response_body), - status=http_client.HTTPException, - side_effect=exceptions.TransportError) + status=http_client.HTTPException) - with pytest.raises(exceptions.TransportError) as excinfo: + with pytest.raises(exceptions.RefreshError) as excinfo: credentials.refresh(request) assert excinfo.match(impersonated_credentials._REFRESH_ERROR)