Skip to content

Commit 93c5270

Browse files
committed
feat(api-core): move mtls and client configuration logic to gapic_v1 public helpers
Introduces client_cert and config_helpers modules containing use_client_cert_effective, get_client_cert_source, and read_environment_variables helpers. Exposes these modules as public in gapic_v1.
1 parent 75d30d5 commit 93c5270

8 files changed

Lines changed: 294 additions & 5 deletions

File tree

packages/google-api-core/google/api_core/gapic_v1/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
# Older Python versions safely ignore this variable.
2626
__lazy_modules__: Set[str] = {
2727
"google.api_core.gapic_v1.client_info",
28+
"google.api_core.gapic_v1.client_cert",
29+
"google.api_core.gapic_v1.config_helpers",
2830
"google.api_core.gapic_v1.routing_header",
2931
}
30-
__all__ = ["client_info", "routing_header"]
32+
__all__ = ["client_info", "client_cert", "config_helpers", "routing_header"]
3133

3234
if _has_grpc:
3335
__lazy_modules__.update(
@@ -41,6 +43,8 @@
4143

4244
from google.api_core.gapic_v1 import ( # noqa: E402
4345
client_info,
46+
client_cert,
47+
config_helpers,
4448
routing_header,
4549
)
4650

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""Helpers for client certificate handling and mTLS authentication."""
18+
19+
import os
20+
from typing import Callable, Optional, Tuple
21+
22+
from google.auth.transport import mtls # type: ignore
23+
24+
25+
def use_client_cert_effective() -> bool:
26+
"""Returns whether client certificate should be used for mTLS if the
27+
google-auth version supports should_use_client_cert automatic mTLS
28+
enablement.
29+
30+
Alternatively, read from the GOOGLE_API_USE_CLIENT_CERTIFICATE env var.
31+
32+
Returns:
33+
bool: whether client certificate should be used for mTLS
34+
Raises:
35+
ValueError: (If using a version of google-auth without
36+
should_use_client_cert and GOOGLE_API_USE_CLIENT_CERTIFICATE is
37+
set to an unexpected value.)
38+
"""
39+
# check if google-auth version supports should_use_client_cert for
40+
# automatic mTLS enablement
41+
if hasattr(mtls, "should_use_client_cert"): # pragma: NO COVER
42+
return mtls.should_use_client_cert()
43+
else: # pragma: NO COVER
44+
# if unsupported, fallback to reading from env var
45+
use_client_cert_str = os.getenv(
46+
"GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"
47+
).lower()
48+
if use_client_cert_str not in ("true", "false"):
49+
raise ValueError(
50+
"Environment variable `GOOGLE_API_USE_CLIENT_CERTIFICATE` "
51+
"must be either `true` or `false`"
52+
)
53+
return use_client_cert_str == "true"
54+
55+
56+
def get_client_cert_source(
57+
provided_cert_source: Optional[Callable[[], Tuple[bytes, bytes]]],
58+
use_cert_flag: bool,
59+
) -> Optional[Callable[[], Tuple[bytes, bytes]]]:
60+
"""Return the client cert source to be used by the client.
61+
62+
Args:
63+
provided_cert_source (Callable[[], Tuple[bytes, bytes]]): The client certificate source provided.
64+
use_cert_flag (bool): A flag indicating whether to use the
65+
client certificate.
66+
67+
Returns:
68+
Callable[[], Tuple[bytes, bytes]] or None: The client cert source to be used by the client.
69+
"""
70+
client_cert_source = None
71+
if use_cert_flag:
72+
if provided_cert_source:
73+
client_cert_source = provided_cert_source
74+
elif (
75+
hasattr(mtls, "has_default_client_cert_source")
76+
and mtls.has_default_client_cert_source()
77+
):
78+
client_cert_source = mtls.default_client_cert_source()
79+
return client_cert_source
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""Helpers for parsing environment variables."""
18+
19+
import os
20+
from typing import Optional, Tuple
21+
22+
from google.auth.exceptions import MutualTLSChannelError # type: ignore
23+
24+
from google.api_core.gapic_v1.client_cert import use_client_cert_effective
25+
26+
27+
def read_environment_variables() -> Tuple[bool, str, Optional[str]]:
28+
"""Returns the environment variables used by the client.
29+
30+
Returns:
31+
Tuple[bool, str, Optional[str]]: returns the
32+
GOOGLE_API_USE_CLIENT_CERTIFICATE, GOOGLE_API_USE_MTLS_ENDPOINT,
33+
and GOOGLE_CLOUD_UNIVERSE_DOMAIN environment variables.
34+
35+
Raises:
36+
ValueError: If GOOGLE_API_USE_CLIENT_CERTIFICATE is not
37+
any of ["true", "false"].
38+
google.auth.exceptions.MutualTLSChannelError: If
39+
GOOGLE_API_USE_MTLS_ENDPOINT is not any of
40+
["auto", "never", "always"].
41+
"""
42+
use_client_cert = use_client_cert_effective()
43+
use_mtls_endpoint = os.getenv("GOOGLE_API_USE_MTLS_ENDPOINT", "auto").lower()
44+
universe_domain_env = os.getenv("GOOGLE_CLOUD_UNIVERSE_DOMAIN")
45+
if use_mtls_endpoint not in ("auto", "never", "always"):
46+
raise MutualTLSChannelError(
47+
"Environment variable `GOOGLE_API_USE_MTLS_ENDPOINT` "
48+
"must be `never`, `auto` or `always`"
49+
)
50+
return use_client_cert, use_mtls_endpoint, universe_domain_env

packages/google-api-core/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ def prerelease_deps(session):
350350
@nox.session(python=DEFAULT_PYTHON_VERSION)
351351
def core_deps_from_source(session):
352352
"""Run the test suite installing dependencies from source."""
353-
default(session, prerelease=True)
353+
default(session, install_deps_from_source=True)
354354

355355

356356
@nox.session(python=DEFAULT_PYTHON_VERSION)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import os
17+
from unittest import mock
18+
import pytest
19+
20+
21+
@pytest.fixture(scope="session", autouse=True)
22+
def mock_mtls_env():
23+
"""Autouse session-scoped fixture to isolate unit tests from workstation mTLS environments."""
24+
with mock.patch.dict(
25+
os.environ,
26+
{
27+
"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false",
28+
"CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false",
29+
},
30+
):
31+
yield
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import os
17+
from unittest import mock
18+
19+
import pytest
20+
21+
22+
from google.api_core.gapic_v1.client_cert import (
23+
get_client_cert_source,
24+
use_client_cert_effective,
25+
)
26+
27+
28+
@mock.patch("google.auth.transport.mtls.should_use_client_cert", create=True)
29+
def test_use_client_cert_effective_with_google_auth(mock_method):
30+
# Test when google-auth supports the method
31+
mock_method.return_value = True
32+
assert use_client_cert_effective() is True
33+
34+
mock_method.return_value = False
35+
assert use_client_cert_effective() is False
36+
37+
38+
@mock.patch.dict(os.environ, {}, clear=True)
39+
def test_use_client_cert_effective_fallback():
40+
# We must patch hasattr to simulate google-auth lacking the method
41+
with mock.patch("google.api_core.gapic_v1.client_cert.hasattr", return_value=False):
42+
# Default is false
43+
assert use_client_cert_effective() is False
44+
45+
env_true = {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"}
46+
with mock.patch.dict(os.environ, env_true):
47+
assert use_client_cert_effective() is True
48+
49+
with mock.patch.dict(
50+
os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false"}
51+
):
52+
assert use_client_cert_effective() is False
53+
54+
with mock.patch.dict(
55+
os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "invalid"}
56+
):
57+
match_str = "must be either `true` or `false`"
58+
with pytest.raises(ValueError, match=match_str):
59+
use_client_cert_effective()
60+
61+
62+
@mock.patch(
63+
"google.auth.transport.mtls.has_default_client_cert_source", create=True
64+
) # noqa: E501
65+
@mock.patch(
66+
"google.auth.transport.mtls.default_client_cert_source", create=True
67+
) # noqa: E501
68+
def test_get_client_cert_source(mock_default, mock_has_default):
69+
mock_default.return_value = b"default_cert"
70+
mock_has_default.return_value = True
71+
72+
# When use_cert_flag is False, return None
73+
assert get_client_cert_source(b"provided", False) is None
74+
75+
# When provided_cert_source is given, return provided
76+
assert get_client_cert_source(b"provided", True) == b"provided" # noqa: E501
77+
78+
# When no provided cert but default is available
79+
assert get_client_cert_source(None, True) == b"default_cert"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import os
17+
from unittest import mock
18+
19+
import pytest
20+
21+
from google.auth.exceptions import MutualTLSChannelError
22+
23+
from google.api_core.gapic_v1.config_helpers import read_environment_variables
24+
25+
26+
@mock.patch(
27+
"google.api_core.gapic_v1.config_helpers.use_client_cert_effective"
28+
) # noqa: E501
29+
@mock.patch.dict(os.environ, clear=True)
30+
def test_read_environment_variables(mock_effective):
31+
mock_effective.return_value = True
32+
os.environ["GOOGLE_API_USE_MTLS_ENDPOINT"] = "always"
33+
os.environ["GOOGLE_CLOUD_UNIVERSE_DOMAIN"] = "custom.com"
34+
35+
cert, mtls, domain = read_environment_variables()
36+
assert cert is True
37+
assert mtls == "always"
38+
assert domain == "custom.com"
39+
40+
41+
@mock.patch.dict(os.environ, clear=True)
42+
def test_read_environment_variables_invalid_mtls():
43+
os.environ["GOOGLE_API_USE_MTLS_ENDPOINT"] = "invalid"
44+
with pytest.raises(
45+
MutualTLSChannelError, match="must be `never`, `auto` or `always`"
46+
):
47+
read_environment_variables()

packages/google-api-core/tests/unit/test_bidi.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@
3131
except ImportError: # pragma: NO COVER
3232
pytest.skip("No GRPC", allow_module_level=True)
3333

34-
from google.api_core import bidi
35-
from google.api_core import exceptions
34+
from google.api_core import bidi, exceptions
3635

3736

3837
class Test_RequestQueueGenerator(object):
@@ -195,7 +194,7 @@ def test_delays_entry_attempts_above_threshold(self):
195194
# (NOTE: not using assert all(...), b/c the coverage check would complain)
196195
for i, entry in enumerate(entries):
197196
if i != 3:
198-
assert entry["reported_wait"] == 0.0
197+
assert entry["reported_wait"] < 0.01
199198

200199
# The delayed entry is expected to have been delayed for a significant
201200
# chunk of the full second, and the actual and reported delay times

0 commit comments

Comments
 (0)