diff --git a/CHANGELOG.md b/CHANGELOG.md index c69a8be10..96a15f463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ [1]: https://pypi.org/project/google-auth/#history +### [1.11.3](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.2...v1.11.3) (2020-03-13) + + +### Bug Fixes + +* fix the scopes so test can pass for a local run ([#450](https://www.github.com/googleapis/google-auth-library-python/issues/450)) ([b2dd77f](https://www.github.com/googleapis/google-auth-library-python/commit/b2dd77fe4a538e1d165fc9d859c9a299f6832cda)) +* only add IAM scope to credentials that can change scopes ([#451](https://www.github.com/googleapis/google-auth-library-python/issues/451)) ([82e224b](https://www.github.com/googleapis/google-auth-library-python/commit/82e224b0854950a5607cd028edbcbcdc3e9e6505)) + ### [1.11.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.1...v1.11.2) (2020-02-14) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f95b1f1dc..bd92ca8d4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -43,21 +43,27 @@ To run a single session, specify it with ``nox -s``:: $ nox -f system_tests/noxfile.py -s service_account + +Project and Credentials Setup +------------------------------- + +Enable the IAM Service Account Credentials API on the project. + To run system tests locally, you will need to set up a data directory :: $ mkdir system_tests/data -Add a service account file and authorized user file to the data directory. -Your directory should look like this :: +Your directory should look like this. Follow the instructions below for creating each file. :: system_tests/ data/ - service_account.json authorized_user.json + impersonated_service_account.json + service_account.json -The files must be named exactly ``service_account.json`` -and ``authorized_user.json``. See `Creating and Managing Service Account Keys`_ for how to -obtain a service account. + +``authorized_user.json`` +~~~~~~~~~~~~~~~~~~~~~~~~ Use the `gcloud CLI`_ to get an authorized user file :: @@ -65,15 +71,41 @@ Use the `gcloud CLI`_ to get an authorized user file :: You will see something like:: - Credentials saved to file: [/usr/local/home/.config/gcloud/application_default_credentials.json]``` + Credentials saved to file: [/usr/local/home/.config/gcloud/application_default_credentials.json] Copy the contents of the file to ``authorized_user.json``. -.. _Creating and Managing Service Account Keys: https://cloud.google.com/iam/docs/creating-managing-service-account-keys +Open the IAM page of the Google Cloud Console. Grant the user the `Service Account Token Creator Role`. +This will allow the user to impersonate service accounts on the project. + .. _gcloud CLI: https://cloud.google.com/sdk/gcloud/ + +``service_account.json`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +Follow `Creating and Managing Service Account Keys`_ to create a service account. + +Copy the credentials file to ``service_account.json``. + +Grant the account associated with ``service_account.json`` the following roles. + +- App Engine Admin (for App Engine tests) +- Service Account Token Creator (for impersonated credentials tests) +- Pub/Sub Viewer (for gRPC tests) +- Storage Object Viewer (for impersonated credentials tests) + +``impersonated_service_account.json`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Follow `Creating and Managing Service Account Keys`_ to create a service account. + +Copy the credentials file to ``impersonated_service_account.json``. + +.. _Creating and Managing Service Account Keys: https://cloud.google.com/iam/docs/creating-managing-service-account-keys + App Engine System Tests -^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~ To run the App Engine tests, you wil need to deploy a default App Engine service. If you already have a default service associated with your project, you can skip this step. diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index bc7031e78..1bb6b8268 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -205,7 +205,11 @@ def __init__( super(Credentials, self).__init__() self._source_credentials = copy.copy(source_credentials) - self._source_credentials._scopes = _IAM_SCOPE + # Service account source credentials must have the _IAM_SCOPE + # added to refresh correctly. User credentials cannot have + # their original scopes modified. + if isinstance(self._source_credentials, credentials.Scoped): + self._source_credentials = self._source_credentials.with_scopes(_IAM_SCOPE) self._target_principal = target_principal self._target_scopes = target_scopes self._delegates = delegates diff --git a/setup.py b/setup.py index f0a20aa77..f80dc1de7 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.11.2" +version = "1.11.3" setup( name="google-auth", diff --git a/system_tests/conftest.py b/system_tests/conftest.py index 189300707..02de84664 100644 --- a/system_tests/conftest.py +++ b/system_tests/conftest.py @@ -25,6 +25,9 @@ HERE = os.path.dirname(__file__) DATA_DIR = os.path.join(HERE, "data") +IMPERSONATED_SERVICE_ACCOUNT_FILE = os.path.join( + DATA_DIR, "impersonated_service_account.json" +) SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, "service_account.json") AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, "authorized_user.json") URLLIB3_HTTP = urllib3.PoolManager(retries=False) @@ -39,6 +42,12 @@ def service_account_file(): yield SERVICE_ACCOUNT_FILE +@pytest.fixture +def impersonated_service_account_file(): + """The full path to a valid service account key file.""" + yield IMPERSONATED_SERVICE_ACCOUNT_FILE + + @pytest.fixture def authorized_user_file(): """The full path to a valid authorized user file.""" diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index e37049e52..811063223 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -170,7 +170,8 @@ def configure_cloud_sdk(session, application_default_credentials, project=False) # Test sesssions TEST_DEPENDENCIES = ["pytest", "requests"] -PYTHON_VERSIONS=['2.7', '3.7'] +PYTHON_VERSIONS = ["2.7", "3.7"] + @nox.session(python=PYTHON_VERSIONS) def service_account(session): @@ -186,6 +187,13 @@ def oauth2_credentials(session): session.run("pytest", "test_oauth2_credentials.py") +@nox.session(python=PYTHON_VERSIONS) +def impersonated_credentials(session): + session.install(*TEST_DEPENDENCIES) + session.install(LIBRARY_DIR) + session.run("pytest", "test_impersonated_credentials.py") + + @nox.session(python=PYTHON_VERSIONS) def default_explicit_service_account(session): session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 1106f8a91..af10c7134 100644 Binary files a/system_tests/secrets.tar.enc and b/system_tests/secrets.tar.enc differ diff --git a/system_tests/test_impersonated_credentials.py b/system_tests/test_impersonated_credentials.py new file mode 100644 index 000000000..6689e8943 --- /dev/null +++ b/system_tests/test_impersonated_credentials.py @@ -0,0 +1,99 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import pytest + +import google.oauth2.credentials +from google.oauth2 import service_account +import google.auth.impersonated_credentials +from google.auth import _helpers + + +GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + + +@pytest.fixture +def service_account_credentials(service_account_file): + yield service_account.Credentials.from_service_account_file(service_account_file) + + +@pytest.fixture +def impersonated_service_account_credentials(impersonated_service_account_file): + yield service_account.Credentials.from_service_account_file( + impersonated_service_account_file + ) + + +def test_refresh_with_user_credentials_as_source( + authorized_user_file, + impersonated_service_account_credentials, + http_request, + token_info, +): + with open(authorized_user_file, "r") as fh: + info = json.load(fh) + + source_credentials = google.oauth2.credentials.Credentials( + None, + refresh_token=info["refresh_token"], + token_uri=GOOGLE_OAUTH2_TOKEN_ENDPOINT, + client_id=info["client_id"], + client_secret=info["client_secret"], + # The source credential needs this scope for the generateAccessToken request + # The user must also have `Service Account Token Creator` on the project + # that owns the impersonated service account. + # See https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + + source_credentials.refresh(http_request) + + target_scopes = [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/analytics", + ] + target_credentials = google.auth.impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal=impersonated_service_account_credentials.service_account_email, + target_scopes=target_scopes, + lifetime=100, + ) + + target_credentials.refresh(http_request) + assert target_credentials.token + + +def test_refresh_with_service_account_credentials_as_source( + http_request, + service_account_credentials, + impersonated_service_account_credentials, + token_info, +): + source_credentials = service_account_credentials.with_scopes(["email"]) + source_credentials.refresh(http_request) + assert source_credentials.token + + target_scopes = [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/analytics", + ] + target_credentials = google.auth.impersonated_credentials.Credentials( + source_credentials=source_credentials, + target_principal=impersonated_service_account_credentials.service_account_email, + target_scopes=target_scopes, + ) + + target_credentials.refresh(http_request) + assert target_credentials.token diff --git a/system_tests/test_oauth2_credentials.py b/system_tests/test_oauth2_credentials.py index 663d4fc21..908db3145 100644 --- a/system_tests/test_oauth2_credentials.py +++ b/system_tests/test_oauth2_credentials.py @@ -42,10 +42,14 @@ def test_refresh(authorized_user_file, http_request, token_info): # Canonical list of scopes at https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login # or do `gcloud auth application-defaut login --help` - assert set(info_scopes) == set( + canonical_scopes = set( [ "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/cloud-platform", "openid", ] ) + # When running the test locally, we always have an additional "accounts.reauth" scope. + canonical_scopes_with_reauth = canonical_scopes.copy() + canonical_scopes_with_reauth.add("https://www.googleapis.com/auth/accounts.reauth") + assert set(info_scopes) == canonical_scopes or set(info_scopes) == canonical_scopes_with_reauth diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 1cfcc7c6c..31075ca84 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -26,6 +26,7 @@ from google.auth import impersonated_credentials from google.auth import transport from google.auth.impersonated_credentials import Credentials +from google.oauth2 import credentials from google.oauth2 import service_account DATA_DIR = os.path.join(os.path.dirname(__file__), "", "data") @@ -102,17 +103,30 @@ class TestImpersonatedCredentials(object): SOURCE_CREDENTIALS = service_account.Credentials( SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI ) + USER_SOURCE_CREDENTIALS = credentials.Credentials(token="ABCDE") - def make_credentials(self, lifetime=LIFETIME, target_principal=TARGET_PRINCIPAL): + def make_credentials( + self, + source_credentials=SOURCE_CREDENTIALS, + lifetime=LIFETIME, + target_principal=TARGET_PRINCIPAL, + ): return Credentials( - source_credentials=self.SOURCE_CREDENTIALS, + source_credentials=source_credentials, target_principal=target_principal, target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=lifetime, ) + def test_make_from_user_credentials(self): + credentials = self.make_credentials( + source_credentials=self.USER_SOURCE_CREDENTIALS + ) + assert not credentials.valid + assert credentials.expired + def test_default_state(self): credentials = self.make_credentials() assert not credentials.valid