Skip to content

Commit 36ecb1d

Browse files
authored
feat: Add trust boundary support for external accounts. (#1809)
* feat: Add trust boundary support for external accounts. * Add trust boundary support to external account authorized users. * Fix lint issues * Implement additional unit tests for external account authorized user. * fix formatting issue * Add a unit test with invalid audiance * add missing unit tests
1 parent 10823c2 commit 36ecb1d

11 files changed

+571
-82
lines changed

google/auth/_constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Shared constants."""
2+
3+
_SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations"
4+
_WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations"
5+
_WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations"

google/auth/external_account.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import json
3737
import re
3838

39+
from google.auth import _constants
3940
from google.auth import _helpers
4041
from google.auth import credentials
4142
from google.auth import exceptions
@@ -81,6 +82,7 @@ class Credentials(
8182
credentials.Scoped,
8283
credentials.CredentialsWithQuotaProject,
8384
credentials.CredentialsWithTokenUri,
85+
credentials.CredentialsWithTrustBoundary,
8486
metaclass=abc.ABCMeta,
8587
):
8688
"""Base class for all external account credentials.
@@ -173,10 +175,7 @@ def __init__(
173175
self._scopes = scopes
174176
self._default_scopes = default_scopes
175177
self._workforce_pool_user_project = workforce_pool_user_project
176-
self._trust_boundary = {
177-
"locations": [],
178-
"encoded_locations": "0x0",
179-
} # expose a placeholder trust boundary value.
178+
self._trust_boundary = trust_boundary
180179

181180
if self._client_id:
182181
self._client_auth = utils.ClientAuthentication(
@@ -242,6 +241,7 @@ def _constructor_args(self):
242241
"scopes": self._scopes,
243242
"default_scopes": self._default_scopes,
244243
"universe_domain": self._universe_domain,
244+
"trust_boundary": self._trust_boundary,
245245
}
246246
if not self.is_workforce_pool:
247247
args.pop("workforce_pool_user_project")
@@ -412,8 +412,23 @@ def get_project_id(self, request):
412412

413413
return None
414414

415-
@_helpers.copy_docstring(credentials.Credentials)
416415
def refresh(self, request):
416+
"""Refreshes the access token.
417+
418+
For impersonated credentials, this method will refresh the underlying
419+
source credentials and the impersonated credentials. For non-impersonated
420+
credentials, it will refresh the access token and the trust boundary.
421+
"""
422+
self._refresh_token(request)
423+
# If we are impersonating, the trust boundary is handled by the
424+
# impersonated credentials object. We need to get it from there.
425+
if self._service_account_impersonation_url:
426+
self._trust_boundary = self._impersonated_credentials._trust_boundary
427+
else:
428+
# Otherwise, refresh the trust boundary for the external account.
429+
self._refresh_trust_boundary(request)
430+
431+
def _refresh_token(self, request):
417432
scopes = self._scopes if self._scopes is not None else self._default_scopes
418433

419434
# Inject client certificate into request.
@@ -463,6 +478,40 @@ def refresh(self, request):
463478

464479
self.expiry = now + lifetime
465480

481+
def _build_trust_boundary_lookup_url(self):
482+
"""Builds and returns the URL for the trust boundary lookup API."""
483+
url = None
484+
# Try to parse as a workload identity pool.
485+
# Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
486+
workload_match = re.search(
487+
r"projects/([^/]+)/locations/global/workloadIdentityPools/([^/]+)",
488+
self._audience,
489+
)
490+
if workload_match:
491+
project_number, pool_id = workload_match.groups()
492+
url = _constants._WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
493+
universe_domain=self._universe_domain,
494+
project_number=project_number,
495+
pool_id=pool_id,
496+
)
497+
else:
498+
# If that fails, try to parse as a workforce pool.
499+
# Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID
500+
workforce_match = re.search(
501+
r"locations/[^/]+/workforcePools/([^/]+)", self._audience
502+
)
503+
if workforce_match:
504+
pool_id = workforce_match.groups()[0]
505+
url = _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
506+
universe_domain=self._universe_domain, pool_id=pool_id
507+
)
508+
509+
if url:
510+
return url
511+
else:
512+
# If both fail, the audience format is invalid.
513+
raise exceptions.InvalidValue("Invalid audience format.")
514+
466515
def _make_copy(self):
467516
kwargs = self._constructor_args()
468517
new_cred = self.__class__(**kwargs)
@@ -489,6 +538,12 @@ def with_universe_domain(self, universe_domain):
489538
cred._universe_domain = universe_domain
490539
return cred
491540

541+
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
542+
def with_trust_boundary(self, trust_boundary):
543+
cred = self._make_copy()
544+
cred._trust_boundary = trust_boundary
545+
return cred
546+
492547
def _should_initialize_impersonated_credentials(self):
493548
return (
494549
self._service_account_impersonation_url is not None
@@ -537,6 +592,7 @@ def _initialize_impersonated_credentials(self):
537592
lifetime=self._service_account_impersonation_options.get(
538593
"token_lifetime_seconds"
539594
),
595+
trust_boundary=self._trust_boundary,
540596
)
541597

542598
def _create_default_metrics_options(self):
@@ -623,6 +679,7 @@ def from_info(cls, info, **kwargs):
623679
universe_domain=info.get(
624680
"universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN
625681
),
682+
trust_boundary=info.get("trust_boundary"),
626683
**kwargs
627684
)
628685

google/auth/external_account_authorized_user.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
import datetime
3737
import io
3838
import json
39+
import re
3940

41+
from google.auth import _constants
4042
from google.auth import _helpers
4143
from google.auth import credentials
4244
from google.auth import exceptions
@@ -50,6 +52,7 @@ class Credentials(
5052
credentials.CredentialsWithQuotaProject,
5153
credentials.ReadOnlyScoped,
5254
credentials.CredentialsWithTokenUri,
55+
credentials.CredentialsWithTrustBoundary,
5356
):
5457
"""Credentials for External Account Authorized Users.
5558
@@ -83,6 +86,7 @@ def __init__(
8386
scopes=None,
8487
quota_project_id=None,
8588
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
89+
trust_boundary=None,
8690
):
8791
"""Instantiates a external account authorized user credentials object.
8892
@@ -108,6 +112,7 @@ def __init__(
108112
create the credentials.
109113
universe_domain (Optional[str]): The universe domain. The default value
110114
is googleapis.com.
115+
trust_boundary (Mapping[str,str]): A credential trust boundary.
111116
112117
Returns:
113118
google.auth.external_account_authorized_user.Credentials: The
@@ -118,7 +123,7 @@ def __init__(
118123
self.token = token
119124
self.expiry = expiry
120125
self._audience = audience
121-
self._refresh_token = refresh_token
126+
self._refresh_token_val = refresh_token
122127
self._token_url = token_url
123128
self._token_info_url = token_info_url
124129
self._client_id = client_id
@@ -128,6 +133,7 @@ def __init__(
128133
self._scopes = scopes
129134
self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN
130135
self._cred_file_path = None
136+
self._trust_boundary = trust_boundary
131137

132138
if not self.valid and not self.can_refresh:
133139
raise exceptions.InvalidOperation(
@@ -164,7 +170,7 @@ def info(self):
164170
def constructor_args(self):
165171
return {
166172
"audience": self._audience,
167-
"refresh_token": self._refresh_token,
173+
"refresh_token": self._refresh_token_val,
168174
"token_url": self._token_url,
169175
"token_info_url": self._token_info_url,
170176
"client_id": self._client_id,
@@ -175,6 +181,7 @@ def constructor_args(self):
175181
"scopes": self._scopes,
176182
"quota_project_id": self._quota_project_id,
177183
"universe_domain": self._universe_domain,
184+
"trust_boundary": self._trust_boundary,
178185
}
179186

180187
@property
@@ -184,7 +191,7 @@ def scopes(self):
184191

185192
@property
186193
def requires_scopes(self):
187-
""" False: OAuth 2.0 credentials have their scopes set when
194+
"""False: OAuth 2.0 credentials have their scopes set when
188195
the initial token is requested and can not be changed."""
189196
return False
190197

@@ -201,13 +208,13 @@ def client_secret(self):
201208
@property
202209
def audience(self):
203210
"""Optional[str]: The STS audience which contains the resource name for the
204-
workforce pool and the provider identifier in that pool."""
211+
workforce pool and the provider identifier in that pool."""
205212
return self._audience
206213

207214
@property
208215
def refresh_token(self):
209216
"""Optional[str]: The OAuth 2.0 refresh token."""
210-
return self._refresh_token
217+
return self._refresh_token_val
211218

212219
@property
213220
def token_url(self):
@@ -226,13 +233,18 @@ def revoke_url(self):
226233

227234
@property
228235
def is_user(self):
229-
""" True: This credential always represents a user."""
236+
"""True: This credential always represents a user."""
230237
return True
231238

232239
@property
233240
def can_refresh(self):
234241
return all(
235-
(self._refresh_token, self._token_url, self._client_id, self._client_secret)
242+
(
243+
self._refresh_token_val,
244+
self._token_url,
245+
self._client_id,
246+
self._client_secret,
247+
)
236248
)
237249

238250
def get_project_id(self, request=None):
@@ -266,7 +278,7 @@ def to_json(self, strip=None):
266278
strip = strip if strip else []
267279
return json.dumps({k: v for (k, v) in self.info.items() if k not in strip})
268280

269-
def refresh(self, request):
281+
def _refresh_token(self, request):
270282
"""Refreshes the access token.
271283
272284
Args:
@@ -285,18 +297,29 @@ def refresh(self, request):
285297
)
286298

287299
now = _helpers.utcnow()
288-
response_data = self._make_sts_request(request)
300+
response_data = self._sts_client.refresh_token(request, self._refresh_token_val)
289301

290302
self.token = response_data.get("access_token")
291303

292304
lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
293305
self.expiry = now + lifetime
294306

295307
if "refresh_token" in response_data:
296-
self._refresh_token = response_data["refresh_token"]
308+
self._refresh_token_val = response_data["refresh_token"]
309+
310+
def _build_trust_boundary_lookup_url(self):
311+
"""Builds and returns the URL for the trust boundary lookup API."""
312+
# Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID
313+
match = re.search(r"locations/[^/]+/workforcePools/([^/]+)", self._audience)
314+
315+
if not match:
316+
raise exceptions.InvalidValue("Invalid workforce pool audience format.")
317+
318+
pool_id = match.groups()[0]
297319

298-
def _make_sts_request(self, request):
299-
return self._sts_client.refresh_token(request, self._refresh_token)
320+
return _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
321+
universe_domain=self._universe_domain, pool_id=pool_id
322+
)
300323

301324
@_helpers.copy_docstring(credentials.Credentials)
302325
def get_cred_info(self):
@@ -331,6 +354,12 @@ def with_universe_domain(self, universe_domain):
331354
cred._universe_domain = universe_domain
332355
return cred
333356

357+
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
358+
def with_trust_boundary(self, trust_boundary):
359+
cred = self._make_copy()
360+
cred._trust_boundary = trust_boundary
361+
return cred
362+
334363
@classmethod
335364
def from_info(cls, info, **kwargs):
336365
"""Creates a Credentials instance from parsed external account info.
@@ -375,6 +404,7 @@ def from_info(cls, info, **kwargs):
375404
universe_domain=info.get(
376405
"universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN
377406
),
407+
trust_boundary=info.get("trust_boundary"),
378408
**kwargs
379409
)
380410

google/auth/impersonated_credentials.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ def _refresh_token(self, request):
286286
self._source_credentials.token_state == credentials.TokenState.STALE
287287
or self._source_credentials.token_state == credentials.TokenState.INVALID
288288
):
289-
self._source_credentials.refresh(request)
289+
self._source_credentials._refresh_token(request)
290290

291291
body = {
292292
"delegates": self._delegates,
@@ -526,13 +526,15 @@ def from_impersonated_service_account_info(cls, info, scopes=None):
526526
target_principal = impersonation_url[start_index + 1 : end_index]
527527
delegates = info.get("delegates")
528528
quota_project_id = info.get("quota_project_id")
529+
trust_boundary = info.get("trust_boundary")
529530

530531
return cls(
531532
source_credentials,
532533
target_principal,
533534
scopes,
534535
delegates,
535536
quota_project_id=quota_project_id,
537+
trust_boundary=trust_boundary,
536538
)
537539

538540

google/oauth2/service_account.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
import copy
7474
import datetime
7575

76+
from google.auth import _constants
7677
from google.auth import _helpers
7778
from google.auth import _service_account_info
7879
from google.auth import credentials
@@ -84,9 +85,6 @@
8485

8586
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
8687
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
87-
_TRUST_BOUNDARY_LOOKUP_ENDPOINT = (
88-
"https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations"
89-
)
9088

9189

9290
class Credentials(
@@ -520,8 +518,9 @@ def _build_trust_boundary_lookup_url(self):
520518
raise ValueError(
521519
"Service account email is required to build the trust boundary lookup URL."
522520
)
523-
return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
524-
self._universe_domain, self._service_account_email
521+
return _constants._SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
522+
universe_domain=self._universe_domain,
523+
service_account_email=self._service_account_email,
525524
)
526525

527526
@_helpers.copy_docstring(credentials.Signing)

0 commit comments

Comments
 (0)