Skip to content

Commit 99be2ce

Browse files
authored
feat: Add trust boundary support for service accounts and impersonation. (#1778)
* feat: adds trust boundary lookup support for SA and impersonated credentials * Add feature flag, fix ud bug, and update no-op response. * Add _build_trust_boundary_lookup_url to external account. * Implement additional unit tests for the trust boundary * implement trust boundary in compute_engine to support GCE instances. * Fix failing unit test, and change acceptable values for the env variable * Add unit tests for gce trust boundary. * Use no op method instead of directly comparing values * fix a typo in calling the method noop * Add x-allowed-location header to all IAM requests. * Support self-signed jwt and refactor refresh to handle refreshing trust boundary in the base class. * Fix failing unit tests * Revert changes to external account class file. * Additional unit tests and update some old ones. * Revert changes to idtoken * Revert the self signed jwt token workaround * revert idtoken and jwt trust boundary tests. * Fix failing github check * Fix failing unit tests in compute engine * fix linter issues * Fix linter issues * remove trust boundary related code from idtoken as it is not a supported credential type * remove unused line in test
1 parent 2dafdb2 commit 99be2ce

17 files changed

+1840
-124
lines changed

google/auth/_helpers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import hashlib
2222
import json
2323
import logging
24+
import os
2425
import sys
2526
from typing import Any, Dict, Mapping, Optional, Union
2627
import urllib
@@ -287,6 +288,46 @@ def unpadded_urlsafe_b64encode(value):
287288
return base64.urlsafe_b64encode(value).rstrip(b"=")
288289

289290

291+
def get_bool_from_env(variable_name, default=False):
292+
"""Gets a boolean value from an environment variable.
293+
294+
The environment variable is interpreted as a boolean with the following
295+
(case-insensitive) rules:
296+
- "true", "1" are considered true.
297+
- "false", "0" are considered false.
298+
Any other values will raise an exception.
299+
300+
Args:
301+
variable_name (str): The name of the environment variable.
302+
default (bool): The default value if the environment variable is not
303+
set.
304+
305+
Returns:
306+
bool: The boolean value of the environment variable.
307+
308+
Raises:
309+
google.auth.exceptions.InvalidValue: If the environment variable is
310+
set to a value that can not be interpreted as a boolean.
311+
"""
312+
value = os.environ.get(variable_name)
313+
314+
if value is None:
315+
return default
316+
317+
value = value.lower()
318+
319+
if value in ("true", "1"):
320+
return True
321+
elif value in ("false", "0"):
322+
return False
323+
else:
324+
raise exceptions.InvalidValue(
325+
'Environment variable "{}" must be one of "true", "false", "1", or "0".'.format(
326+
variable_name
327+
)
328+
)
329+
330+
290331
def is_python_3():
291332
"""Check if the Python interpreter is Python 2 or 3.
292333

google/auth/compute_engine/credentials.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@
3030
from google.auth.compute_engine import _metadata
3131
from google.oauth2 import _client
3232

33+
_TRUST_BOUNDARY_LOOKUP_ENDPOINT = (
34+
"https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations"
35+
)
36+
3337

3438
class Credentials(
3539
credentials.Scoped,
3640
credentials.CredentialsWithQuotaProject,
3741
credentials.CredentialsWithUniverseDomain,
42+
credentials.CredentialsWithTrustBoundary,
3843
):
3944
"""Compute Engine Credentials.
4045
@@ -61,6 +66,7 @@ def __init__(
6166
scopes=None,
6267
default_scopes=None,
6368
universe_domain=None,
69+
trust_boundary=None,
6470
):
6571
"""
6672
Args:
@@ -76,6 +82,7 @@ def __init__(
7682
provided or None, credential will attempt to fetch the value
7783
from metadata server. If metadata server doesn't have universe
7884
domain endpoint, then the default googleapis.com will be used.
85+
trust_boundary (Mapping[str,str]): A credential trust boundary.
7986
"""
8087
super(Credentials, self).__init__()
8188
self._service_account_email = service_account_email
@@ -86,6 +93,7 @@ def __init__(
8693
if universe_domain:
8794
self._universe_domain = universe_domain
8895
self._universe_domain_cached = True
96+
self._trust_boundary = trust_boundary
8997

9098
def _retrieve_info(self, request):
9199
"""Retrieve information about the service account.
@@ -100,16 +108,22 @@ def _retrieve_info(self, request):
100108
request, service_account=self._service_account_email
101109
)
102110

111+
if not info or "email" not in info:
112+
raise exceptions.RefreshError(
113+
"Unexpected response from metadata server: "
114+
"service account info is missing 'email' field."
115+
)
116+
103117
self._service_account_email = info["email"]
104118

105119
# Don't override scopes requested by the user.
106120
if self._scopes is None:
107-
self._scopes = info["scopes"]
121+
self._scopes = info.get("scopes")
108122

109123
def _metric_header_for_usage(self):
110124
return metrics.CRED_TYPE_SA_MDS
111125

112-
def refresh(self, request):
126+
def _refresh_token(self, request):
113127
"""Refresh the access token and scopes.
114128
115129
Args:
@@ -132,6 +146,37 @@ def refresh(self, request):
132146
new_exc = exceptions.RefreshError(caught_exc)
133147
raise new_exc from caught_exc
134148

149+
def _build_trust_boundary_lookup_url(self):
150+
"""Builds and returns the URL for the trust boundary lookup API for GCE."""
151+
# If the service account email is 'default', we need to get the
152+
# actual email address from the metadata server.
153+
if self._service_account_email == "default":
154+
from google.auth.transport import requests as google_auth_requests
155+
156+
request = google_auth_requests.Request()
157+
try:
158+
info = _metadata.get_service_account_info(request, "default")
159+
if not info or "email" not in info:
160+
raise exceptions.RefreshError(
161+
"Unexpected response from metadata server: "
162+
"service account info is missing 'email' field."
163+
)
164+
self._service_account_email = info["email"]
165+
166+
except exceptions.TransportError as e:
167+
# If fetching the service account email fails due to a transport error,
168+
# it means we cannot build the trust boundary lookup URL.
169+
# Wrap this in a RefreshError so it's caught by _refresh_trust_boundary.
170+
raise exceptions.RefreshError(
171+
"Failed to get service account email for trust boundary lookup: {}".format(
172+
e
173+
)
174+
) from e
175+
176+
return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
177+
self.universe_domain, self.service_account_email
178+
)
179+
135180
@property
136181
def service_account_email(self):
137182
"""The service account email.
@@ -173,8 +218,9 @@ def with_quota_project(self, quota_project_id):
173218
quota_project_id=quota_project_id,
174219
scopes=self._scopes,
175220
default_scopes=self._default_scopes,
221+
universe_domain=self._universe_domain,
222+
trust_boundary=self._trust_boundary,
176223
)
177-
creds._universe_domain = self._universe_domain
178224
creds._universe_domain_cached = self._universe_domain_cached
179225
return creds
180226

@@ -188,8 +234,9 @@ def with_scopes(self, scopes, default_scopes=None):
188234
default_scopes=default_scopes,
189235
service_account_email=self._service_account_email,
190236
quota_project_id=self._quota_project_id,
237+
universe_domain=self._universe_domain,
238+
trust_boundary=self._trust_boundary,
191239
)
192-
creds._universe_domain = self._universe_domain
193240
creds._universe_domain_cached = self._universe_domain_cached
194241
return creds
195242

@@ -200,9 +247,23 @@ def with_universe_domain(self, universe_domain):
200247
default_scopes=self._default_scopes,
201248
service_account_email=self._service_account_email,
202249
quota_project_id=self._quota_project_id,
250+
trust_boundary=self._trust_boundary,
203251
universe_domain=universe_domain,
204252
)
205253

254+
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
255+
def with_trust_boundary(self, trust_boundary):
256+
creds = self.__class__(
257+
service_account_email=self._service_account_email,
258+
quota_project_id=self._quota_project_id,
259+
scopes=self._scopes,
260+
default_scopes=self._default_scopes,
261+
universe_domain=self._universe_domain,
262+
trust_boundary=trust_boundary,
263+
)
264+
creds._universe_domain_cached = self._universe_domain_cached
265+
return creds
266+
206267

207268
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
208269
_DEFAULT_TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token"
@@ -275,7 +336,7 @@ def __init__(
275336

276337
if use_metadata_identity_endpoint:
277338
if token_uri or additional_claims or service_account_email or signer:
278-
raise exceptions.MalformedError(
339+
raise ValueError(
279340
"If use_metadata_identity_endpoint is set, token_uri, "
280341
"additional_claims, service_account_email, signer arguments"
281342
" must not be set"
@@ -366,7 +427,7 @@ def with_token_uri(self, token_uri):
366427
# since the signer is already instantiated,
367428
# the request is not needed
368429
if self._use_metadata_identity_endpoint:
369-
raise exceptions.MalformedError(
430+
raise ValueError(
370431
"If use_metadata_identity_endpoint is set, token_uri" " must not be set"
371432
)
372433
else:

0 commit comments

Comments
 (0)