Skip to content

Commit a1fd6f3

Browse files
feat: auto-enable mTLS when supported certificates are detected (#2686)
* feat: auto-enable mTLS when supported certificates are detected Signed-off-by: Radhika Agrawal <[email protected]> * feat: Add docstring, update version check and lint errors fix Signed-off-by: Radhika Agrawal <[email protected]> * chore: Update the testcases to check against google auth version number and skip for unsupported version number Signed-off-by: Radhika Agrawal <[email protected]> * fix: fix the import for parse_version_to_tuple Signed-off-by: Radhika Agrawal <[email protected]> * fix: Fix the tests to add the parse_version_to_tuple function Signed-off-by: Radhika Agrawal <[email protected]> * fix: Minor fix for parse version function Signed-off-by: Radhika Agrawal <[email protected]> --------- Signed-off-by: Radhika Agrawal <[email protected]>
1 parent 623c34f commit a1fd6f3

File tree

2 files changed

+184
-44
lines changed

2 files changed

+184
-44
lines changed

googleapiclient/discovery.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -649,16 +649,23 @@ def build_from_document(
649649

650650
# Obtain client cert and create mTLS http channel if cert exists.
651651
client_cert_to_use = None
652-
use_client_cert = os.getenv(GOOGLE_API_USE_CLIENT_CERTIFICATE, "false")
653-
if not use_client_cert in ("true", "false"):
654-
raise MutualTLSChannelError(
655-
"Unsupported GOOGLE_API_USE_CLIENT_CERTIFICATE value. Accepted values: true, false"
652+
if hasattr(mtls, "should_use_client_cert"):
653+
use_client_cert = mtls.should_use_client_cert()
654+
else:
655+
# if unsupported, fallback to reading from env var
656+
use_client_cert_str = os.getenv(
657+
"GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"
658+
).lower()
659+
use_client_cert = use_client_cert_str == "true"
660+
if use_client_cert_str not in ("true", "false"):
661+
raise MutualTLSChannelError(
662+
"Unsupported GOOGLE_API_USE_CLIENT_CERTIFICATE value. Accepted values: true, false"
656663
)
657664
if client_options and client_options.client_cert_source:
658665
raise MutualTLSChannelError(
659666
"ClientOptions.client_cert_source is not supported, please use ClientOptions.client_encrypted_cert_source."
660667
)
661-
if use_client_cert == "true":
668+
if use_client_cert:
662669
if (
663670
client_options
664671
and hasattr(client_options, "client_encrypted_cert_source")

tests/test_discovery.py

Lines changed: 172 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import json
3333
import os
3434
import pickle
35+
import pytest
3536
import re
3637
import sys
3738
import unittest
@@ -40,6 +41,7 @@
4041

4142
import google.api_core.exceptions
4243
import google.auth.credentials
44+
from google.auth import __version__ as auth_version
4345
from google.auth.exceptions import MutualTLSChannelError
4446
import google_auth_httplib2
4547
import httplib2
@@ -62,46 +64,29 @@
6264
HAS_UNIVERSE = False
6365

6466
from googleapiclient import _helpers as util
65-
from googleapiclient.discovery import (
66-
DISCOVERY_URI,
67-
MEDIA_BODY_PARAMETER_DEFAULT_VALUE,
68-
MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE,
69-
STACK_QUERY_PARAMETER_DEFAULT_VALUE,
70-
STACK_QUERY_PARAMETERS,
71-
V1_DISCOVERY_URI,
72-
V2_DISCOVERY_URI,
73-
APICoreVersionError,
74-
ResourceMethodParameters,
75-
_fix_up_media_path_base_url,
76-
_fix_up_media_upload,
77-
_fix_up_method_description,
78-
_fix_up_parameters,
79-
_urljoin,
80-
build,
81-
build_from_document,
82-
key2param,
83-
)
67+
from googleapiclient.discovery import (DISCOVERY_URI,
68+
MEDIA_BODY_PARAMETER_DEFAULT_VALUE,
69+
MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE,
70+
STACK_QUERY_PARAMETER_DEFAULT_VALUE,
71+
STACK_QUERY_PARAMETERS,
72+
V1_DISCOVERY_URI, V2_DISCOVERY_URI,
73+
APICoreVersionError,
74+
ResourceMethodParameters,
75+
_fix_up_media_path_base_url,
76+
_fix_up_media_upload,
77+
_fix_up_method_description,
78+
_fix_up_parameters, _urljoin, build,
79+
build_from_document, key2param)
8480
from googleapiclient.discovery_cache import DISCOVERY_DOC_MAX_AGE
8581
from googleapiclient.discovery_cache.base import Cache
86-
from googleapiclient.errors import (
87-
HttpError,
88-
InvalidJsonError,
89-
MediaUploadSizeError,
90-
ResumableUploadError,
91-
UnacceptableMimeTypeError,
92-
UnknownApiNameOrVersion,
93-
UnknownFileType,
94-
)
95-
from googleapiclient.http import (
96-
HttpMock,
97-
HttpMockSequence,
98-
MediaFileUpload,
99-
MediaIoBaseUpload,
100-
MediaUpload,
101-
MediaUploadProgress,
102-
build_http,
103-
tunnel_patch,
104-
)
82+
from googleapiclient.errors import (HttpError, InvalidJsonError,
83+
MediaUploadSizeError, ResumableUploadError,
84+
UnacceptableMimeTypeError,
85+
UnknownApiNameOrVersion, UnknownFileType)
86+
from googleapiclient.http import (HttpMock, HttpMockSequence, MediaFileUpload,
87+
MediaIoBaseUpload, MediaUpload,
88+
MediaUploadProgress, build_http,
89+
tunnel_patch)
10590
from googleapiclient.model import JsonModel
10691
from googleapiclient.schema import Schemas
10792

@@ -156,6 +141,28 @@ def read_datafile(filename, mode="r"):
156141
with open(datafile(filename), mode=mode) as f:
157142
return f.read()
158143

144+
def parse_version_to_tuple(version_string):
145+
"""Safely converts a semantic version string to a comparable tuple of integers.
146+
147+
Example: "4.25.8" -> (4, 25, 8)
148+
Ignores non-numeric parts and handles common version formats.
149+
150+
Args:
151+
version_string: Version string in the format "x.y.z" or "x.y.z<suffix>"
152+
153+
Returns:
154+
Tuple of integers for the parsed version string.
155+
"""
156+
parts = []
157+
for part in version_string.split("."):
158+
try:
159+
parts.append(int(part))
160+
except ValueError:
161+
# If it's a non-numeric part (e.g., '1.0.0b1' -> 'b1'), stop here.
162+
# This is a simplification compared to 'packaging.parse_version', but sufficient
163+
# for comparing strictly numeric semantic versions.
164+
break
165+
return tuple(parts)
159166

160167
class SetupHttplib2(unittest.TestCase):
161168
def test_retries(self):
@@ -778,7 +785,19 @@ def test_self_signed_jwt_disabled(self):
778785

779786
REGULAR_ENDPOINT = "https://www.googleapis.com/plus/v1/"
780787
MTLS_ENDPOINT = "https://www.mtls.googleapis.com/plus/v1/"
781-
788+
CONFIG_DATA_WITH_WORKLOAD = {
789+
"version": 1,
790+
"cert_configs": {
791+
"workload": {
792+
"cert_path": "path/to/cert/file",
793+
"key_path": "path/to/key/file",
794+
}
795+
},
796+
}
797+
CONFIG_DATA_WITHOUT_WORKLOAD = {
798+
"version": 1,
799+
"cert_configs": {},
800+
}
782801

783802
class DiscoveryFromDocumentMutualTLS(unittest.TestCase):
784803
MOCK_CREDENTIALS = mock.Mock(spec=google.auth.credentials.Credentials)
@@ -884,6 +903,55 @@ def test_mtls_with_provided_client_cert(
884903
self.check_http_client_cert(plus, has_client_cert=use_client_cert)
885904
self.assertEqual(plus._baseUrl, base_url)
886905

906+
@parameterized.expand(
907+
[
908+
("never", "", CONFIG_DATA_WITH_WORKLOAD , REGULAR_ENDPOINT),
909+
("auto", "", CONFIG_DATA_WITH_WORKLOAD, MTLS_ENDPOINT),
910+
("always", "", CONFIG_DATA_WITH_WORKLOAD, MTLS_ENDPOINT),
911+
("never", "", CONFIG_DATA_WITHOUT_WORKLOAD, REGULAR_ENDPOINT),
912+
("auto", "", CONFIG_DATA_WITHOUT_WORKLOAD, REGULAR_ENDPOINT),
913+
("always", "", CONFIG_DATA_WITHOUT_WORKLOAD, MTLS_ENDPOINT),
914+
]
915+
)
916+
@pytest.mark.skipif(
917+
parse_version_to_tuple(auth_version) < (2,43,0),
918+
reason="automatic mtls enablement when supported certs present only"
919+
"enabled in google-auth<=2.43.0"
920+
)
921+
def test_mtls_with_provided_client_cert_unset_environment_variable(
922+
self, use_mtls_env, use_client_cert, config_data, base_url
923+
):
924+
"""Tests that mTLS is correctly handled when a client certificate is provided.
925+
926+
This test case verifies that when a client certificate is explicitly provided
927+
via `client_options` and GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the
928+
discovery document build process correctly configures the base URL for mTLS
929+
or regular endpoints based on the `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable.
930+
"""
931+
if hasattr(google.auth.transport.mtls, "should_use_client_cert"):
932+
discovery = read_datafile("plus.json")
933+
config_filename = "mock_certificate_config.json"
934+
config_file_content = json.dumps(config_data)
935+
m = mock.mock_open(read_data=config_file_content)
936+
937+
with mock.patch.dict(
938+
"os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env}
939+
):
940+
with mock.patch.dict(
941+
"os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert}
942+
):
943+
with mock.patch("builtins.open", m):
944+
with mock.patch.dict("os.environ", {"GOOGLE_API_CERTIFICATE_CONFIG": config_filename}):
945+
plus = build_from_document(
946+
discovery,
947+
credentials=self.MOCK_CREDENTIALS,
948+
client_options={
949+
"client_encrypted_cert_source": self.client_encrypted_cert_source
950+
},
951+
)
952+
self.assertIsNotNone(plus)
953+
self.assertEqual(plus._baseUrl, base_url)
954+
887955
@parameterized.expand(
888956
[
889957
("never", "true"),
@@ -961,6 +1029,71 @@ def test_mtls_with_default_client_cert(
9611029
self.assertIsNotNone(plus)
9621030
self.check_http_client_cert(plus, has_client_cert=use_client_cert)
9631031
self.assertEqual(plus._baseUrl, base_url)
1032+
@parameterized.expand(
1033+
[
1034+
("never", "", CONFIG_DATA_WITH_WORKLOAD, REGULAR_ENDPOINT),
1035+
("auto", "", CONFIG_DATA_WITH_WORKLOAD, MTLS_ENDPOINT),
1036+
("always", "", CONFIG_DATA_WITH_WORKLOAD, MTLS_ENDPOINT),
1037+
("never", "", CONFIG_DATA_WITHOUT_WORKLOAD, REGULAR_ENDPOINT),
1038+
("auto", "", CONFIG_DATA_WITHOUT_WORKLOAD, REGULAR_ENDPOINT),
1039+
("always", "", CONFIG_DATA_WITHOUT_WORKLOAD, MTLS_ENDPOINT),
1040+
]
1041+
)
1042+
@mock.patch(
1043+
"google.auth.transport.mtls.has_default_client_cert_source", autospec=True
1044+
)
1045+
@mock.patch(
1046+
"google.auth.transport.mtls.default_client_encrypted_cert_source", autospec=True
1047+
)
1048+
@pytest.mark.skipif(
1049+
parse_version_to_tuple(auth_version) < (2,43,0),
1050+
reason="automatic mtls enablement when supported certs present only"
1051+
"enabled in google-auth<=2.43.0"
1052+
)
1053+
def test_mtls_with_default_client_cert_with_unset_environment_variable(
1054+
self,
1055+
use_mtls_env,
1056+
use_client_cert,
1057+
config_data,
1058+
base_url,
1059+
default_client_encrypted_cert_source,
1060+
has_default_client_cert_source,
1061+
):
1062+
"""Tests mTLS handling when falling back to a default client certificate.
1063+
1064+
This test simulates the scenario where no client certificate is explicitly
1065+
provided, and the library successfully finds and uses a default client
1066+
certificate when GOOGLE_API_USE_CLIENT_CERTIFICATE is unset. It mocks the
1067+
default certificate discovery process and checks that the base URL is
1068+
correctly set for mTLS or regular endpoints depending on the
1069+
`GOOGLE_API_USE_MTLS_ENDPOINT` environment variable.
1070+
"""
1071+
if hasattr(google.auth.transport.mtls, "should_use_client_cert"):
1072+
has_default_client_cert_source.return_value = True
1073+
default_client_encrypted_cert_source.return_value = (
1074+
self.client_encrypted_cert_source
1075+
)
1076+
discovery = read_datafile("plus.json")
1077+
config_filename = "mock_certificate_config.json"
1078+
config_file_content = json.dumps(config_data)
1079+
m = mock.mock_open(read_data=config_file_content)
1080+
1081+
with mock.patch.dict(
1082+
"os.environ", {"GOOGLE_API_USE_MTLS_ENDPOINT": use_mtls_env}
1083+
):
1084+
with mock.patch.dict(
1085+
"os.environ", {"GOOGLE_API_USE_CLIENT_CERTIFICATE": use_client_cert}
1086+
):
1087+
with mock.patch("builtins.open", m):
1088+
with mock.patch.dict("os.environ", {"GOOGLE_API_CERTIFICATE_CONFIG": config_filename}):
1089+
plus = build_from_document(
1090+
discovery,
1091+
credentials=self.MOCK_CREDENTIALS,
1092+
adc_cert_path=self.ADC_CERT_PATH,
1093+
adc_key_path=self.ADC_KEY_PATH,
1094+
)
1095+
self.assertIsNotNone(plus)
1096+
self.assertEqual(plus._baseUrl, base_url)
9641097

9651098
@parameterized.expand(
9661099
[

0 commit comments

Comments
 (0)