Skip to content

Commit 1270217

Browse files
aeitzmanlsiracclundin25
authored
feat: adds support for X509 workload credential type (#1541)
* feat: adds support for X509 workload credential type * fix: PR comments * Apply suggestions from code review Co-authored-by: Leo <[email protected]> * fix: responding to PR comments * Apply suggestions from code review Co-authored-by: Carl Lundin <[email protected]> * renaming functions, adding comments, and removing auth_request temp var * Apply suggestions from code review Co-authored-by: Carl Lundin <[email protected]> * chore: Update test credentials. * linting --------- Co-authored-by: Leo <[email protected]> Co-authored-by: Carl Lundin <[email protected]> Co-authored-by: Carl Lundin <[email protected]>
1 parent 2150bf2 commit 1270217

File tree

6 files changed

+463
-71
lines changed

6 files changed

+463
-71
lines changed

google/auth/external_account.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import copy
3232
from dataclasses import dataclass
3333
import datetime
34+
import functools
3435
import io
3536
import json
3637
import re
@@ -394,6 +395,12 @@ def get_project_id(self, request):
394395
def refresh(self, request):
395396
scopes = self._scopes if self._scopes is not None else self._default_scopes
396397

398+
# Inject client certificate into request.
399+
if self._mtls_required():
400+
request = functools.partial(
401+
request, cert=self._get_mtls_cert_and_key_paths()
402+
)
403+
397404
if self._should_initialize_impersonated_credentials():
398405
self._impersonated_credentials = self._initialize_impersonated_credentials()
399406

@@ -523,6 +530,33 @@ def _create_default_metrics_options(self):
523530

524531
return metrics_options
525532

533+
def _mtls_required(self):
534+
"""Returns a boolean representing whether the current credential is configured
535+
for mTLS and should add a certificate to the outgoing calls to the sts and service
536+
account impersonation endpoint.
537+
538+
Returns:
539+
bool: True if the credential is configured for mTLS, False if it is not.
540+
"""
541+
return False
542+
543+
def _get_mtls_cert_and_key_paths(self):
544+
"""Gets the file locations for a certificate and private key file
545+
to be used for configuring mTLS for the sts and service account
546+
impersonation calls. Currently only expected to return a value when using
547+
X509 workload identity federation.
548+
549+
Returns:
550+
Tuple[str, str]: The cert and key file locations as strings in a tuple.
551+
552+
Raises:
553+
NotImplementedError: When the current credential is not configured for
554+
mTLS.
555+
"""
556+
raise NotImplementedError(
557+
"_get_mtls_cert_and_key_location must be implemented."
558+
)
559+
526560
@classmethod
527561
def from_info(cls, info, **kwargs):
528562
"""Creates a Credentials instance from parsed external account info.

google/auth/identity_pool.py

Lines changed: 100 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from google.auth import _helpers
4949
from google.auth import exceptions
5050
from google.auth import external_account
51+
from google.auth.transport import _mtls_helper
5152

5253

5354
class SubjectTokenSupplier(metaclass=abc.ABCMeta):
@@ -141,6 +142,14 @@ def get_subject_token(self, context, request):
141142
)
142143

143144

145+
class _X509Supplier(SubjectTokenSupplier):
146+
"""Internal supplier for X509 workload credentials. This class is used internally and always returns an empty string as the subject token."""
147+
148+
@_helpers.copy_docstring(SubjectTokenSupplier)
149+
def get_subject_token(self, context, request):
150+
return ""
151+
152+
144153
def _parse_token_data(token_content, format_type="text", subject_token_field_name=None):
145154
if format_type == "text":
146155
token = token_content.content
@@ -247,6 +256,7 @@ def __init__(
247256
self._subject_token_supplier = subject_token_supplier
248257
self._credential_source_file = None
249258
self._credential_source_url = None
259+
self._credential_source_certificate = None
250260
else:
251261
if not isinstance(credential_source, Mapping):
252262
self._credential_source_executable = None
@@ -255,76 +265,70 @@ def __init__(
255265
)
256266
self._credential_source_file = credential_source.get("file")
257267
self._credential_source_url = credential_source.get("url")
258-
self._credential_source_headers = credential_source.get("headers")
259-
credential_source_format = credential_source.get("format", {})
260-
# Get credential_source format type. When not provided, this
261-
# defaults to text.
262-
self._credential_source_format_type = (
263-
credential_source_format.get("type") or "text"
264-
)
268+
self._credential_source_certificate = credential_source.get("certificate")
269+
265270
# environment_id is only supported in AWS or dedicated future external
266271
# account credentials.
267272
if "environment_id" in credential_source:
268273
raise exceptions.MalformedError(
269274
"Invalid Identity Pool credential_source field 'environment_id'"
270275
)
271-
if self._credential_source_format_type not in ["text", "json"]:
272-
raise exceptions.MalformedError(
273-
"Invalid credential_source format '{}'".format(
274-
self._credential_source_format_type
275-
)
276-
)
277-
# For JSON types, get the required subject_token field name.
278-
if self._credential_source_format_type == "json":
279-
self._credential_source_field_name = credential_source_format.get(
280-
"subject_token_field_name"
281-
)
282-
if self._credential_source_field_name is None:
283-
raise exceptions.MalformedError(
284-
"Missing subject_token_field_name for JSON credential_source format"
285-
)
286-
else:
287-
self._credential_source_field_name = None
288276

289-
if self._credential_source_file and self._credential_source_url:
290-
raise exceptions.MalformedError(
291-
"Ambiguous credential_source. 'file' is mutually exclusive with 'url'."
292-
)
293-
if not self._credential_source_file and not self._credential_source_url:
294-
raise exceptions.MalformedError(
295-
"Missing credential_source. A 'file' or 'url' must be provided."
296-
)
277+
# check that only one of file, url, or certificate are provided.
278+
self._validate_single_source()
279+
280+
if self._credential_source_certificate:
281+
self._validate_certificate_config()
282+
else:
283+
self._validate_file_or_url_config(credential_source)
297284

298285
if self._credential_source_file:
299286
self._subject_token_supplier = _FileSupplier(
300287
self._credential_source_file,
301288
self._credential_source_format_type,
302289
self._credential_source_field_name,
303290
)
304-
else:
291+
elif self._credential_source_url:
305292
self._subject_token_supplier = _UrlSupplier(
306293
self._credential_source_url,
307294
self._credential_source_format_type,
308295
self._credential_source_field_name,
309296
self._credential_source_headers,
310297
)
298+
else: # self._credential_source_certificate
299+
self._subject_token_supplier = _X509Supplier()
311300

312301
@_helpers.copy_docstring(external_account.Credentials)
313302
def retrieve_subject_token(self, request):
314303
return self._subject_token_supplier.get_subject_token(
315304
self._supplier_context, request
316305
)
317306

307+
def _get_mtls_cert_and_key_paths(self):
308+
if self._credential_source_certificate is None:
309+
raise exceptions.RefreshError(
310+
'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.'
311+
)
312+
else:
313+
return _mtls_helper._get_workload_cert_and_key_paths(
314+
self._certificate_config_location
315+
)
316+
317+
def _mtls_required(self):
318+
return self._credential_source_certificate is not None
319+
318320
def _create_default_metrics_options(self):
319321
metrics_options = super(Credentials, self)._create_default_metrics_options()
320-
# Check that credential source is a dict before checking for file vs url. This check needs to be done
322+
# Check that credential source is a dict before checking for credential type. This check needs to be done
321323
# here because the external_account credential constructor needs to pass the metrics options to the
322324
# impersonated credential object before the identity_pool credentials are validated.
323325
if isinstance(self._credential_source, Mapping):
324326
if self._credential_source.get("file"):
325327
metrics_options["source"] = "file"
326-
else:
328+
elif self._credential_source.get("url"):
327329
metrics_options["source"] = "url"
330+
else:
331+
metrics_options["source"] = "x509"
328332
else:
329333
metrics_options["source"] = "programmatic"
330334
return metrics_options
@@ -339,6 +343,67 @@ def _constructor_args(self):
339343
args.update({"subject_token_supplier": self._subject_token_supplier})
340344
return args
341345

346+
def _validate_certificate_config(self):
347+
self._certificate_config_location = self._credential_source_certificate.get(
348+
"certificate_config_location"
349+
)
350+
use_default = self._credential_source_certificate.get(
351+
"use_default_certificate_config"
352+
)
353+
if self._certificate_config_location and use_default:
354+
raise exceptions.MalformedError(
355+
"Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true."
356+
)
357+
if not self._certificate_config_location and not use_default:
358+
raise exceptions.MalformedError(
359+
"Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided."
360+
)
361+
362+
def _validate_file_or_url_config(self, credential_source):
363+
self._credential_source_headers = credential_source.get("headers")
364+
credential_source_format = credential_source.get("format", {})
365+
# Get credential_source format type. When not provided, this
366+
# defaults to text.
367+
self._credential_source_format_type = (
368+
credential_source_format.get("type") or "text"
369+
)
370+
if self._credential_source_format_type not in ["text", "json"]:
371+
raise exceptions.MalformedError(
372+
"Invalid credential_source format '{}'".format(
373+
self._credential_source_format_type
374+
)
375+
)
376+
# For JSON types, get the required subject_token field name.
377+
if self._credential_source_format_type == "json":
378+
self._credential_source_field_name = credential_source_format.get(
379+
"subject_token_field_name"
380+
)
381+
if self._credential_source_field_name is None:
382+
raise exceptions.MalformedError(
383+
"Missing subject_token_field_name for JSON credential_source format"
384+
)
385+
else:
386+
self._credential_source_field_name = None
387+
388+
def _validate_single_source(self):
389+
credential_sources = [
390+
self._credential_source_file,
391+
self._credential_source_url,
392+
self._credential_source_certificate,
393+
]
394+
valid_credential_sources = list(
395+
filter(lambda source: source is not None, credential_sources)
396+
)
397+
398+
if len(valid_credential_sources) > 1:
399+
raise exceptions.MalformedError(
400+
"Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.."
401+
)
402+
if len(valid_credential_sources) != 1:
403+
raise exceptions.MalformedError(
404+
"Missing credential_source. A 'file', 'url', or 'certificate' must be provided."
405+
)
406+
342407
@classmethod
343408
def from_info(cls, info, **kwargs):
344409
"""Creates an Identity Pool Credentials instance from parsed external account info.

google/auth/transport/_mtls_helper.py

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,50 @@ def _get_workload_cert_and_key(certificate_config_path=None):
105105
google.auth.exceptions.ClientCertError: if problems occurs when retrieving
106106
the certificate or key information.
107107
"""
108-
absolute_path = _get_cert_config_path(certificate_config_path)
108+
109+
cert_path, key_path = _get_workload_cert_and_key_paths(certificate_config_path)
110+
111+
if cert_path is None and key_path is None:
112+
return None, None
113+
114+
return _read_cert_and_key_files(cert_path, key_path)
115+
116+
117+
def _get_cert_config_path(certificate_config_path=None):
118+
"""Get the certificate configuration path based on the following order:
119+
120+
1: Explicit override, if set
121+
2: Environment variable, if set
122+
3: Well-known location
123+
124+
Returns "None" if the selected config file does not exist.
125+
126+
Args:
127+
certificate_config_path (string): The certificate config path. If provided, the well known
128+
location and environment variable will be ignored.
129+
130+
Returns:
131+
The absolute path of the certificate config file, and None if the file does not exist.
132+
"""
133+
134+
if certificate_config_path is None:
135+
env_path = environ.get(_CERTIFICATE_CONFIGURATION_ENV, None)
136+
if env_path is not None and env_path != "":
137+
certificate_config_path = env_path
138+
else:
139+
certificate_config_path = _CERTIFICATE_CONFIGURATION_DEFAULT_PATH
140+
141+
certificate_config_path = path.expanduser(certificate_config_path)
142+
if not path.exists(certificate_config_path):
143+
return None
144+
return certificate_config_path
145+
146+
147+
def _get_workload_cert_and_key_paths(config_path):
148+
absolute_path = _get_cert_config_path(config_path)
109149
if absolute_path is None:
110150
return None, None
151+
111152
data = _load_json_file(absolute_path)
112153

113154
if "cert_configs" not in data:
@@ -142,37 +183,7 @@ def _get_workload_cert_and_key(certificate_config_path=None):
142183
)
143184
key_path = workload["key_path"]
144185

145-
return _read_cert_and_key_files(cert_path, key_path)
146-
147-
148-
def _get_cert_config_path(certificate_config_path=None):
149-
"""Gets the certificate configuration full path using the following order of precedence:
150-
151-
1: Explicit override, if set
152-
2: Environment variable, if set
153-
3: Well-known location
154-
155-
Returns "None" if the selected config file does not exist.
156-
157-
Args:
158-
certificate_config_path (string): The certificate config path. If provided, the well known
159-
location and environment variable will be ignored.
160-
161-
Returns:
162-
The absolute path of the certificate config file, and None if the file does not exist.
163-
"""
164-
165-
if certificate_config_path is None:
166-
env_path = environ.get(_CERTIFICATE_CONFIGURATION_ENV, None)
167-
if env_path is not None and env_path != "":
168-
certificate_config_path = env_path
169-
else:
170-
certificate_config_path = _CERTIFICATE_CONFIGURATION_DEFAULT_PATH
171-
172-
certificate_config_path = path.expanduser(certificate_config_path)
173-
if not path.exists(certificate_config_path):
174-
return None
175-
return certificate_config_path
186+
return cert_path, key_path
176187

177188

178189
def _read_cert_and_key_files(cert_path, key_path):

system_tests/secrets.tar.enc

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)