diff --git a/googleapiclient/discovery.py b/googleapiclient/discovery.py index 7244c5bd610..13bdf967818 100644 --- a/googleapiclient/discovery.py +++ b/googleapiclient/discovery.py @@ -117,6 +117,10 @@ } _PAGE_TOKEN_NAMES = ("pageToken", "nextPageToken") +# Parameters controlling mTLS behavior. See https://google.aip.dev/auth/4114. +GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE" +GOOGLE_API_USE_MTLS_ENDPOINT = "GOOGLE_API_USE_MTLS_ENDPOINT" + # Parameters accepted by the stack, but not visible via discovery. # TODO(dhermes): Remove 'userip' in 'v2'. STACK_QUERY_PARAMETERS = frozenset(["trace", "pp", "userip", "strict"]) @@ -215,15 +219,30 @@ def build( cache: googleapiclient.discovery_cache.base.CacheBase, an optional cache object for the discovery documents. client_options: Mapping object or google.api_core.client_options, client - options to set user options on the client. The API endpoint should be set - through client_options. client_cert_source is not supported, client cert - should be provided using client_encrypted_cert_source instead. + options to set user options on the client. + (1) The API endpoint should be set through client_options. If API endpoint + is not set, `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable can be used + to control which endpoint to use. + (2) client_cert_source is not supported, client cert should be provided using + client_encrypted_cert_source instead. In order to use the provided client + cert, `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be + set to `true`. + More details on the environment variables are here: + https://google.aip.dev/auth/4114 adc_cert_path: str, client certificate file path to save the application default client certificate for mTLS. This field is required if you want to - use the default client certificate. + use the default client certificate. `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable must be set to `true` in order to use this field, + otherwise this field doesn't nothing. + More details on the environment variables are here: + https://google.aip.dev/auth/4114 adc_key_path: str, client encrypted private key file path to save the application default client encrypted private key for mTLS. This field is required if you want to use the default client certificate. + `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be set to + `true` in order to use this field, otherwise this field doesn't nothing. + More details on the environment variables are here: + https://google.aip.dev/auth/4114 num_retries: Integer, number of times to retry discovery with randomized exponential backoff in case of intermittent/connection issues. @@ -392,15 +411,30 @@ def build_from_document( google.auth.credentials.Credentials, credentials to be used for authentication. client_options: Mapping object or google.api_core.client_options, client - options to set user options on the client. The API endpoint should be set - through client_options. client_cert_source is not supported, client cert - should be provided using client_encrypted_cert_source instead. + options to set user options on the client. + (1) The API endpoint should be set through client_options. If API endpoint + is not set, `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable can be used + to control which endpoint to use. + (2) client_cert_source is not supported, client cert should be provided using + client_encrypted_cert_source instead. In order to use the provided client + cert, `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be + set to `true`. + More details on the environment variables are here: + https://google.aip.dev/auth/4114 adc_cert_path: str, client certificate file path to save the application default client certificate for mTLS. This field is required if you want to - use the default client certificate. + use the default client certificate. `GOOGLE_API_USE_CLIENT_CERTIFICATE` + environment variable must be set to `true` in order to use this field, + otherwise this field doesn't nothing. + More details on the environment variables are here: + https://google.aip.dev/auth/4114 adc_key_path: str, client encrypted private key file path to save the application default client encrypted private key for mTLS. This field is required if you want to use the default client certificate. + `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be set to + `true` in order to use this field, otherwise this field doesn't nothing. + More details on the environment variables are here: + https://google.aip.dev/auth/4114 Returns: A Resource object with methods for interacting with the service. @@ -469,20 +503,26 @@ def build_from_document( # Obtain client cert and create mTLS http channel if cert exists. client_cert_to_use = None + use_client_cert = os.getenv(GOOGLE_API_USE_CLIENT_CERTIFICATE, "false") + if not use_client_cert in ("true", "false"): + raise MutualTLSChannelError( + "Unsupported GOOGLE_API_USE_CLIENT_CERTIFICATE value. Accepted values: true, false" + ) if client_options and client_options.client_cert_source: raise MutualTLSChannelError( "ClientOptions.client_cert_source is not supported, please use ClientOptions.client_encrypted_cert_source." ) - if ( - client_options - and hasattr(client_options, "client_encrypted_cert_source") - and client_options.client_encrypted_cert_source - ): - client_cert_to_use = client_options.client_encrypted_cert_source - elif adc_cert_path and adc_key_path and mtls.has_default_client_cert_source(): - client_cert_to_use = mtls.default_client_encrypted_cert_source( - adc_cert_path, adc_key_path - ) + if use_client_cert == "true": + if ( + client_options + and hasattr(client_options, "client_encrypted_cert_source") + and client_options.client_encrypted_cert_source + ): + client_cert_to_use = client_options.client_encrypted_cert_source + elif adc_cert_path and adc_key_path and mtls.has_default_client_cert_source(): + client_cert_to_use = mtls.default_client_encrypted_cert_source( + adc_cert_path, adc_key_path + ) if client_cert_to_use: cert_path, key_path, passphrase = client_cert_to_use() @@ -503,17 +543,17 @@ def build_from_document( not client_options or not client_options.api_endpoint ): mtls_endpoint = urljoin(service["mtlsRootUrl"], service["servicePath"]) - use_mtls_env = os.getenv("GOOGLE_API_USE_MTLS", "never") + use_mtls_endpoint = os.getenv(GOOGLE_API_USE_MTLS_ENDPOINT, "auto") - if not use_mtls_env in ("never", "auto", "always"): + if not use_mtls_endpoint in ("never", "auto", "always"): raise MutualTLSChannelError( - "Unsupported GOOGLE_API_USE_MTLS value. Accepted values: never, auto, always" + "Unsupported GOOGLE_API_USE_MTLS_ENDPOINT value. Accepted values: never, auto, always" ) # Switch to mTLS endpoint, if environment variable is "always", or # environment varibable is "auto" and client cert exists. - if use_mtls_env == "always" or ( - use_mtls_env == "auto" and client_cert_to_use + if use_mtls_endpoint == "always" or ( + use_mtls_endpoint == "auto" and client_cert_to_use ): base = mtls_endpoint diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 87cc8ed0d4c..7e44a3ea3b1 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -595,12 +595,12 @@ class DiscoveryFromDocumentMutualTLS(unittest.TestCase): ADC_KEY_PATH = "adc_key_path" ADC_PASSPHRASE = "adc_passphrase" - def check_http_client_cert(self, resource, has_client_cert=False): + def check_http_client_cert(self, resource, has_client_cert="false"): if isinstance(resource._http, google_auth_httplib2.AuthorizedHttp): certs = list(resource._http.http.certificates.iter("")) else: certs = list(resource._http.certificates.iter("")) - if has_client_cert: + if has_client_cert == "true": self.assertEqual(len(certs), 1) self.assertEqual( certs[0], (self.ADC_KEY_PATH, self.ADC_CERT_PATH, self.ADC_PASSPHRASE) @@ -611,67 +611,127 @@ def check_http_client_cert(self, resource, has_client_cert=False): def client_encrypted_cert_source(self): return self.ADC_CERT_PATH, self.ADC_KEY_PATH, self.ADC_PASSPHRASE - def test_mtls_not_trigger_if_http_provided(self): + @parameterized.expand( + [ + ("never", "true"), + ("auto", "true"), + ("always", "true"), + ("never", "false"), + ("auto", "false"), + ("always", "false"), + ] + ) + def test_mtls_not_trigger_if_http_provided(self, use_mtls_env, use_client_cert): discovery = read_datafile("plus.json") - plus = build_from_document(discovery, http=httplib2.Http()) - self.assertIsNotNone(plus) - self.assertEqual(plus._baseUrl, REGULAR_ENDPOINT) - self.check_http_client_cert(plus, has_client_cert=False) - def test_exception_with_client_cert_source(self): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env} + ): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert} + ): + plus = build_from_document(discovery, http=httplib2.Http()) + self.assertIsNotNone(plus) + self.assertEqual(plus._baseUrl, REGULAR_ENDPOINT) + self.check_http_client_cert(plus, has_client_cert="false") + + @parameterized.expand( + [ + ("never", "true"), + ("auto", "true"), + ("always", "true"), + ("never", "false"), + ("auto", "false"), + ("always", "false"), + ] + ) + def test_exception_with_client_cert_source(self, use_mtls_env, use_client_cert): discovery = read_datafile("plus.json") - with self.assertRaises(MutualTLSChannelError): - build_from_document( - discovery, - credentials=self.MOCK_CREDENTIALS, - client_options={"client_cert_source": mock.Mock()}, - ) + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env} + ): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert} + ): + with self.assertRaises(MutualTLSChannelError): + build_from_document( + discovery, + credentials=self.MOCK_CREDENTIALS, + client_options={"client_cert_source": mock.Mock()}, + ) @parameterized.expand( [ - ("never", REGULAR_ENDPOINT), - ("auto", MTLS_ENDPOINT), - ("always", MTLS_ENDPOINT), + ("never", "true", REGULAR_ENDPOINT), + ("auto", "true", MTLS_ENDPOINT), + ("always", "true", MTLS_ENDPOINT), + ("never", "false", REGULAR_ENDPOINT), + ("auto", "false", REGULAR_ENDPOINT), + ("always", "false", MTLS_ENDPOINT), ] ) - def test_mtls_with_provided_client_cert(self, use_mtls_env, base_url): + def test_mtls_with_provided_client_cert( + self, use_mtls_env, use_client_cert, base_url + ): discovery = read_datafile("plus.json") - with mock.patch.dict("os.environ", {"GOOGLE_API_USE_MTLS": use_mtls_env}): - plus = build_from_document( - discovery, - credentials=self.MOCK_CREDENTIALS, - client_options={ - "client_encrypted_cert_source": self.client_encrypted_cert_source - }, - ) - self.assertIsNotNone(plus) - self.check_http_client_cert(plus, has_client_cert=True) - self.assertEqual(plus._baseUrl, base_url) + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env} + ): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert} + ): + plus = build_from_document( + discovery, + credentials=self.MOCK_CREDENTIALS, + client_options={ + "client_encrypted_cert_source": self.client_encrypted_cert_source + }, + ) + self.assertIsNotNone(plus) + self.check_http_client_cert(plus, has_client_cert=use_client_cert) + self.assertEqual(plus._baseUrl, base_url) - @parameterized.expand(["never", "auto", "always"]) - def test_endpoint_not_switch(self, use_mtls_env): + @parameterized.expand( + [ + ("never", "true"), + ("auto", "true"), + ("always", "true"), + ("never", "false"), + ("auto", "false"), + ("always", "false"), + ] + ) + def test_endpoint_not_switch(self, use_mtls_env, use_client_cert): # Test endpoint is not switched if user provided api endpoint discovery = read_datafile("plus.json") - with mock.patch.dict("os.environ", {"GOOGLE_API_USE_MTLS": use_mtls_env}): - plus = build_from_document( - discovery, - credentials=self.MOCK_CREDENTIALS, - client_options={ - "api_endpoint": "https://foo.googleapis.com", - "client_encrypted_cert_source": self.client_encrypted_cert_source, - }, - ) - self.assertIsNotNone(plus) - self.check_http_client_cert(plus, has_client_cert=True) - self.assertEqual(plus._baseUrl, "https://foo.googleapis.com") + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env} + ): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert} + ): + plus = build_from_document( + discovery, + credentials=self.MOCK_CREDENTIALS, + client_options={ + "api_endpoint": "https://foo.googleapis.com", + "client_encrypted_cert_source": self.client_encrypted_cert_source, + }, + ) + self.assertIsNotNone(plus) + self.check_http_client_cert(plus, has_client_cert=use_client_cert) + self.assertEqual(plus._baseUrl, "https://foo.googleapis.com") @parameterized.expand( [ - ("never", REGULAR_ENDPOINT), - ("auto", MTLS_ENDPOINT), - ("always", MTLS_ENDPOINT), + ("never", "true", REGULAR_ENDPOINT), + ("auto", "true", MTLS_ENDPOINT), + ("always", "true", MTLS_ENDPOINT), + ("never", "false", REGULAR_ENDPOINT), + ("auto", "false", REGULAR_ENDPOINT), + ("always", "false", MTLS_ENDPOINT), ] ) @mock.patch( @@ -683,6 +743,7 @@ def test_endpoint_not_switch(self, use_mtls_env): def test_mtls_with_default_client_cert( self, use_mtls_env, + use_client_cert, base_url, default_client_encrypted_cert_source, has_default_client_cert_source, @@ -693,43 +754,56 @@ def test_mtls_with_default_client_cert( ) discovery = read_datafile("plus.json") - with mock.patch.dict("os.environ", {"GOOGLE_API_USE_MTLS": use_mtls_env}): - plus = build_from_document( - discovery, - credentials=self.MOCK_CREDENTIALS, - adc_cert_path=self.ADC_CERT_PATH, - adc_key_path=self.ADC_KEY_PATH, - ) - self.assertIsNotNone(plus) - self.check_http_client_cert(plus, has_client_cert=True) - self.assertEqual(plus._baseUrl, base_url) + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env} + ): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert} + ): + plus = build_from_document( + discovery, + credentials=self.MOCK_CREDENTIALS, + adc_cert_path=self.ADC_CERT_PATH, + adc_key_path=self.ADC_KEY_PATH, + ) + self.assertIsNotNone(plus) + self.check_http_client_cert(plus, has_client_cert=use_client_cert) + self.assertEqual(plus._baseUrl, base_url) @parameterized.expand( [ - ("never", REGULAR_ENDPOINT), - ("auto", REGULAR_ENDPOINT), - ("always", MTLS_ENDPOINT), + ("never", "true", REGULAR_ENDPOINT), + ("auto", "true", REGULAR_ENDPOINT), + ("always", "true", MTLS_ENDPOINT), + ("never", "false", REGULAR_ENDPOINT), + ("auto", "false", REGULAR_ENDPOINT), + ("always", "false", MTLS_ENDPOINT), ] ) @mock.patch( "google.auth.transport.mtls.has_default_client_cert_source", autospec=True ) def test_mtls_with_no_client_cert( - self, use_mtls_env, base_url, has_default_client_cert_source + self, use_mtls_env, use_client_cert, base_url, has_default_client_cert_source ): has_default_client_cert_source.return_value = False discovery = read_datafile("plus.json") - with mock.patch.dict("os.environ", {"GOOGLE_API_USE_MTLS": use_mtls_env}): - plus = build_from_document( - discovery, - credentials=self.MOCK_CREDENTIALS, - adc_cert_path=self.ADC_CERT_PATH, - adc_key_path=self.ADC_KEY_PATH, - ) - self.assertIsNotNone(plus) - self.check_http_client_cert(plus, has_client_cert=False) - self.assertEqual(plus._baseUrl, base_url) + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env} + ): + with mock.patch.dict( + "os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert} + ): + plus = build_from_document( + discovery, + credentials=self.MOCK_CREDENTIALS, + adc_cert_path=self.ADC_CERT_PATH, + adc_key_path=self.ADC_KEY_PATH, + ) + self.assertIsNotNone(plus) + self.check_http_client_cert(plus, has_client_cert="false") + self.assertEqual(plus._baseUrl, base_url) class DiscoveryFromHttp(unittest.TestCase):