blob: f588981a07915dd67ad5f1c74c13fb631086e11a [file] [log] [blame]
bojeil-googled4d7f382021-02-16 12:33:20 -08001# Copyright 2020 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""External Account Credentials.
16
17This module provides credentials that exchange workload identity pool external
18credentials for Google access tokens. This facilitates accessing Google Cloud
19Platform resources from on-prem and non-Google Cloud platforms (e.g. AWS,
20Microsoft Azure, OIDC identity providers), using native credentials retrieved
21from the current environment without the need to copy, save and manage
22long-lived service account credentials.
23
24Specifically, this is intended to use access tokens acquired using the GCP STS
25token exchange endpoint following the `OAuth 2.0 Token Exchange`_ spec.
26
27.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693
28"""
29
30import abc
bojeil-googlef97499c2021-06-09 07:58:25 -070031import copy
bojeil-googled4d7f382021-02-16 12:33:20 -080032import datetime
33import json
bojeil-googlef97499c2021-06-09 07:58:25 -070034import re
bojeil-googled4d7f382021-02-16 12:33:20 -080035
bojeil-googled4d7f382021-02-16 12:33:20 -080036from google.auth import _helpers
37from google.auth import credentials
38from google.auth import exceptions
39from google.auth import impersonated_credentials
40from google.oauth2 import sts
41from google.oauth2 import utils
42
bojeil-googlef97499c2021-06-09 07:58:25 -070043# External account JSON type identifier.
44_EXTERNAL_ACCOUNT_JSON_TYPE = "external_account"
bojeil-googled4d7f382021-02-16 12:33:20 -080045# The token exchange grant_type used for exchanging credentials.
46_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
47# The token exchange requested_token_type. This is always an access_token.
48_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
49# Cloud resource manager URL used to retrieve project information.
50_CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/"
51
52
Tres Seaver560cf1e2021-08-03 16:35:54 -040053class Credentials(
54 credentials.Scoped, credentials.CredentialsWithQuotaProject, metaclass=abc.ABCMeta
55):
bojeil-googled4d7f382021-02-16 12:33:20 -080056 """Base class for all external account credentials.
57
58 This is used to instantiate Credentials for exchanging external account
59 credentials for Google access token and authorizing requests to Google APIs.
60 The base class implements the common logic for exchanging external account
61 credentials for Google access tokens.
62 """
63
64 def __init__(
65 self,
66 audience,
67 subject_token_type,
68 token_url,
69 credential_source,
70 service_account_impersonation_url=None,
71 client_id=None,
72 client_secret=None,
73 quota_project_id=None,
74 scopes=None,
75 default_scopes=None,
bojeil-google993bab22021-09-21 14:00:15 -070076 workforce_pool_user_project=None,
bojeil-googled4d7f382021-02-16 12:33:20 -080077 ):
78 """Instantiates an external account credentials object.
79
80 Args:
81 audience (str): The STS audience field.
82 subject_token_type (str): The subject token type.
83 token_url (str): The STS endpoint URL.
84 credential_source (Mapping): The credential source dictionary.
85 service_account_impersonation_url (Optional[str]): The optional service account
86 impersonation generateAccessToken URL.
87 client_id (Optional[str]): The optional client ID.
88 client_secret (Optional[str]): The optional client secret.
89 quota_project_id (Optional[str]): The optional quota project ID.
90 scopes (Optional[Sequence[str]]): Optional scopes to request during the
91 authorization grant.
92 default_scopes (Optional[Sequence[str]]): Default scopes passed by a
93 Google client library. Use 'scopes' for user-defined scopes.
bojeil-google993bab22021-09-21 14:00:15 -070094 workforce_pool_user_project (Optona[str]): The optional workforce pool user
95 project number when the credential corresponds to a workforce pool and not
96 a workload identity pool. The underlying principal must still have
97 serviceusage.services.use IAM permission to use the project for
98 billing/quota.
bojeil-googled4d7f382021-02-16 12:33:20 -080099 Raises:
100 google.auth.exceptions.RefreshError: If the generateAccessToken
101 endpoint returned an error.
102 """
103 super(Credentials, self).__init__()
104 self._audience = audience
105 self._subject_token_type = subject_token_type
106 self._token_url = token_url
107 self._credential_source = credential_source
108 self._service_account_impersonation_url = service_account_impersonation_url
109 self._client_id = client_id
110 self._client_secret = client_secret
111 self._quota_project_id = quota_project_id
112 self._scopes = scopes
113 self._default_scopes = default_scopes
bojeil-google993bab22021-09-21 14:00:15 -0700114 self._workforce_pool_user_project = workforce_pool_user_project
bojeil-googled4d7f382021-02-16 12:33:20 -0800115
116 if self._client_id:
117 self._client_auth = utils.ClientAuthentication(
118 utils.ClientAuthType.basic, self._client_id, self._client_secret
119 )
120 else:
121 self._client_auth = None
122 self._sts_client = sts.Client(self._token_url, self._client_auth)
123
124 if self._service_account_impersonation_url:
125 self._impersonated_credentials = self._initialize_impersonated_credentials()
126 else:
127 self._impersonated_credentials = None
128 self._project_id = None
129
bojeil-google993bab22021-09-21 14:00:15 -0700130 if not self.is_workforce_pool and self._workforce_pool_user_project:
131 # Workload identity pools do not support workforce pool user projects.
132 raise ValueError(
133 "workforce_pool_user_project should not be set for non-workforce pool "
134 "credentials"
135 )
136
bojeil-googled4d7f382021-02-16 12:33:20 -0800137 @property
bojeil-googlef97499c2021-06-09 07:58:25 -0700138 def info(self):
139 """Generates the dictionary representation of the current credentials.
140
141 Returns:
142 Mapping: The dictionary representation of the credentials. This is the
143 reverse of "from_info" defined on the subclasses of this class. It is
144 useful for serializing the current credentials so it can deserialized
145 later.
146 """
147 config_info = {
148 "type": _EXTERNAL_ACCOUNT_JSON_TYPE,
149 "audience": self._audience,
150 "subject_token_type": self._subject_token_type,
151 "token_url": self._token_url,
152 "service_account_impersonation_url": self._service_account_impersonation_url,
153 "credential_source": copy.deepcopy(self._credential_source),
154 "quota_project_id": self._quota_project_id,
155 "client_id": self._client_id,
156 "client_secret": self._client_secret,
bojeil-google993bab22021-09-21 14:00:15 -0700157 "workforce_pool_user_project": self._workforce_pool_user_project,
bojeil-googlef97499c2021-06-09 07:58:25 -0700158 }
bojeil-googlea5e6b652021-06-09 12:27:21 -0700159 return {key: value for key, value in config_info.items() if value is not None}
bojeil-googlef97499c2021-06-09 07:58:25 -0700160
161 @property
162 def service_account_email(self):
163 """Returns the service account email if service account impersonation is used.
164
165 Returns:
166 Optional[str]: The service account email if impersonation is used. Otherwise
167 None is returned.
168 """
169 if self._service_account_impersonation_url:
170 # Parse email from URL. The formal looks as follows:
171 # https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
172 url = self._service_account_impersonation_url
173 start_index = url.rfind("/")
174 end_index = url.find(":generateAccessToken")
175 if start_index != -1 and end_index != -1 and start_index < end_index:
176 start_index = start_index + 1
177 return url[start_index:end_index]
178 return None
179
180 @property
181 def is_user(self):
182 """Returns whether the credentials represent a user (True) or workload (False).
183 Workloads behave similarly to service accounts. Currently workloads will use
184 service account impersonation but will eventually not require impersonation.
185 As a result, this property is more reliable than the service account email
186 property in determining if the credentials represent a user or workload.
187
188 Returns:
189 bool: True if the credentials represent a user. False if they represent a
190 workload.
191 """
192 # If service account impersonation is used, the credentials will always represent a
193 # service account.
194 if self._service_account_impersonation_url:
195 return False
bojeil-google993bab22021-09-21 14:00:15 -0700196 return self.is_workforce_pool
197
198 @property
199 def is_workforce_pool(self):
200 """Returns whether the credentials represent a workforce pool (True) or
201 workload (False) based on the credentials' audience.
202
203 This will also return True for impersonated workforce pool credentials.
204
205 Returns:
206 bool: True if the credentials represent a workforce pool. False if they
207 represent a workload.
208 """
bojeil-googlef97499c2021-06-09 07:58:25 -0700209 # Workforce pools representing users have the following audience format:
210 # //iam.googleapis.com/locations/$location/workforcePools/$poolId/providers/$providerId
211 p = re.compile(r"//iam\.googleapis\.com/locations/[^/]+/workforcePools/")
bojeil-google993bab22021-09-21 14:00:15 -0700212 return p.match(self._audience or "") is not None
bojeil-googlef97499c2021-06-09 07:58:25 -0700213
214 @property
bojeil-googled4d7f382021-02-16 12:33:20 -0800215 def requires_scopes(self):
216 """Checks if the credentials requires scopes.
217
218 Returns:
219 bool: True if there are no scopes set otherwise False.
220 """
221 return not self._scopes and not self._default_scopes
222
223 @property
224 def project_number(self):
225 """Optional[str]: The project number corresponding to the workload identity pool."""
226
227 # STS audience pattern:
228 # //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...
229 components = self._audience.split("/")
230 try:
231 project_index = components.index("projects")
232 if project_index + 1 < len(components):
233 return components[project_index + 1] or None
234 except ValueError:
235 return None
236
237 @_helpers.copy_docstring(credentials.Scoped)
238 def with_scopes(self, scopes, default_scopes=None):
bojeil-google993bab22021-09-21 14:00:15 -0700239 d = dict(
bojeil-googled4d7f382021-02-16 12:33:20 -0800240 audience=self._audience,
241 subject_token_type=self._subject_token_type,
242 token_url=self._token_url,
243 credential_source=self._credential_source,
244 service_account_impersonation_url=self._service_account_impersonation_url,
245 client_id=self._client_id,
246 client_secret=self._client_secret,
247 quota_project_id=self._quota_project_id,
248 scopes=scopes,
249 default_scopes=default_scopes,
bojeil-google993bab22021-09-21 14:00:15 -0700250 workforce_pool_user_project=self._workforce_pool_user_project,
bojeil-googled4d7f382021-02-16 12:33:20 -0800251 )
bojeil-google993bab22021-09-21 14:00:15 -0700252 if not self.is_workforce_pool:
253 d.pop("workforce_pool_user_project")
254 return self.__class__(**d)
bojeil-googled4d7f382021-02-16 12:33:20 -0800255
256 @abc.abstractmethod
257 def retrieve_subject_token(self, request):
258 """Retrieves the subject token using the credential_source object.
259
260 Args:
261 request (google.auth.transport.Request): A callable used to make
262 HTTP requests.
263 Returns:
264 str: The retrieved subject token.
265 """
266 # pylint: disable=missing-raises-doc
267 # (pylint doesn't recognize that this is abstract)
268 raise NotImplementedError("retrieve_subject_token must be implemented")
269
270 def get_project_id(self, request):
bojeil-google993bab22021-09-21 14:00:15 -0700271 """Retrieves the project ID corresponding to the workload identity or workforce pool.
272 For workforce pool credentials, it returns the project ID corresponding to
273 the workforce_pool_user_project.
bojeil-googled4d7f382021-02-16 12:33:20 -0800274
275 When not determinable, None is returned.
276
277 This is introduced to support the current pattern of using the Auth library:
278
279 credentials, project_id = google.auth.default()
280
281 The resource may not have permission (resourcemanager.projects.get) to
282 call this API or the required scopes may not be selected:
283 https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
284
285 Args:
286 request (google.auth.transport.Request): A callable used to make
287 HTTP requests.
288 Returns:
289 Optional[str]: The project ID corresponding to the workload identity pool
bojeil-google993bab22021-09-21 14:00:15 -0700290 or workforce pool if determinable.
bojeil-googled4d7f382021-02-16 12:33:20 -0800291 """
292 if self._project_id:
293 # If already retrieved, return the cached project ID value.
294 return self._project_id
295 scopes = self._scopes if self._scopes is not None else self._default_scopes
296 # Scopes are required in order to retrieve a valid access token.
bojeil-google993bab22021-09-21 14:00:15 -0700297 project_number = self.project_number or self._workforce_pool_user_project
298 if project_number and scopes:
bojeil-googled4d7f382021-02-16 12:33:20 -0800299 headers = {}
bojeil-google993bab22021-09-21 14:00:15 -0700300 url = _CLOUD_RESOURCE_MANAGER + project_number
bojeil-googled4d7f382021-02-16 12:33:20 -0800301 self.before_request(request, "GET", url, headers)
302 response = request(url=url, method="GET", headers=headers)
303
304 response_body = (
305 response.data.decode("utf-8")
306 if hasattr(response.data, "decode")
307 else response.data
308 )
309 response_data = json.loads(response_body)
310
311 if response.status == 200:
312 # Cache result as this field is immutable.
313 self._project_id = response_data.get("projectId")
314 return self._project_id
315
316 return None
317
318 @_helpers.copy_docstring(credentials.Credentials)
319 def refresh(self, request):
320 scopes = self._scopes if self._scopes is not None else self._default_scopes
321 if self._impersonated_credentials:
322 self._impersonated_credentials.refresh(request)
323 self.token = self._impersonated_credentials.token
324 self.expiry = self._impersonated_credentials.expiry
325 else:
326 now = _helpers.utcnow()
bojeil-google993bab22021-09-21 14:00:15 -0700327 additional_options = None
328 # Do not pass workforce_pool_user_project when client authentication
329 # is used. The client ID is sufficient for determining the user project.
330 if self._workforce_pool_user_project and not self._client_id:
331 additional_options = {"userProject": self._workforce_pool_user_project}
bojeil-googled4d7f382021-02-16 12:33:20 -0800332 response_data = self._sts_client.exchange_token(
333 request=request,
334 grant_type=_STS_GRANT_TYPE,
335 subject_token=self.retrieve_subject_token(request),
336 subject_token_type=self._subject_token_type,
337 audience=self._audience,
338 scopes=scopes,
339 requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
bojeil-google993bab22021-09-21 14:00:15 -0700340 additional_options=additional_options,
bojeil-googled4d7f382021-02-16 12:33:20 -0800341 )
342 self.token = response_data.get("access_token")
343 lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
344 self.expiry = now + lifetime
345
346 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
347 def with_quota_project(self, quota_project_id):
348 # Return copy of instance with the provided quota project ID.
bojeil-google993bab22021-09-21 14:00:15 -0700349 d = dict(
bojeil-googled4d7f382021-02-16 12:33:20 -0800350 audience=self._audience,
351 subject_token_type=self._subject_token_type,
352 token_url=self._token_url,
353 credential_source=self._credential_source,
354 service_account_impersonation_url=self._service_account_impersonation_url,
355 client_id=self._client_id,
356 client_secret=self._client_secret,
357 quota_project_id=quota_project_id,
358 scopes=self._scopes,
359 default_scopes=self._default_scopes,
bojeil-google993bab22021-09-21 14:00:15 -0700360 workforce_pool_user_project=self._workforce_pool_user_project,
bojeil-googled4d7f382021-02-16 12:33:20 -0800361 )
bojeil-google993bab22021-09-21 14:00:15 -0700362 if not self.is_workforce_pool:
363 d.pop("workforce_pool_user_project")
364 return self.__class__(**d)
bojeil-googled4d7f382021-02-16 12:33:20 -0800365
366 def _initialize_impersonated_credentials(self):
367 """Generates an impersonated credentials.
368
369 For more details, see `projects.serviceAccounts.generateAccessToken`_.
370
371 .. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
372
373 Returns:
374 impersonated_credentials.Credential: The impersonated credentials
375 object.
376
377 Raises:
378 google.auth.exceptions.RefreshError: If the generateAccessToken
379 endpoint returned an error.
380 """
381 # Return copy of instance with no service account impersonation.
bojeil-google993bab22021-09-21 14:00:15 -0700382 d = dict(
bojeil-googled4d7f382021-02-16 12:33:20 -0800383 audience=self._audience,
384 subject_token_type=self._subject_token_type,
385 token_url=self._token_url,
386 credential_source=self._credential_source,
387 service_account_impersonation_url=None,
388 client_id=self._client_id,
389 client_secret=self._client_secret,
390 quota_project_id=self._quota_project_id,
391 scopes=self._scopes,
392 default_scopes=self._default_scopes,
bojeil-google993bab22021-09-21 14:00:15 -0700393 workforce_pool_user_project=self._workforce_pool_user_project,
bojeil-googled4d7f382021-02-16 12:33:20 -0800394 )
bojeil-google993bab22021-09-21 14:00:15 -0700395 if not self.is_workforce_pool:
396 d.pop("workforce_pool_user_project")
397 source_credentials = self.__class__(**d)
bojeil-googled4d7f382021-02-16 12:33:20 -0800398
399 # Determine target_principal.
bojeil-googlef97499c2021-06-09 07:58:25 -0700400 target_principal = self.service_account_email
401 if not target_principal:
bojeil-googled4d7f382021-02-16 12:33:20 -0800402 raise exceptions.RefreshError(
403 "Unable to determine target principal from service account impersonation URL."
404 )
405
406 scopes = self._scopes if self._scopes is not None else self._default_scopes
407 # Initialize and return impersonated credentials.
408 return impersonated_credentials.Credentials(
409 source_credentials=source_credentials,
410 target_principal=target_principal,
411 target_scopes=scopes,
412 quota_project_id=self._quota_project_id,
413 iam_endpoint_override=self._service_account_impersonation_url,
414 )