bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame] | 1 | # 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 | |
| 17 | This module provides credentials that exchange workload identity pool external |
| 18 | credentials for Google access tokens. This facilitates accessing Google Cloud |
| 19 | Platform resources from on-prem and non-Google Cloud platforms (e.g. AWS, |
| 20 | Microsoft Azure, OIDC identity providers), using native credentials retrieved |
| 21 | from the current environment without the need to copy, save and manage |
| 22 | long-lived service account credentials. |
| 23 | |
| 24 | Specifically, this is intended to use access tokens acquired using the GCP STS |
| 25 | token 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 | |
| 30 | import abc |
bojeil-google | f97499c | 2021-06-09 07:58:25 -0700 | [diff] [blame^] | 31 | import copy |
bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame] | 32 | import datetime |
| 33 | import json |
bojeil-google | f97499c | 2021-06-09 07:58:25 -0700 | [diff] [blame^] | 34 | import re |
bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame] | 35 | |
| 36 | import six |
| 37 | |
| 38 | from google.auth import _helpers |
| 39 | from google.auth import credentials |
| 40 | from google.auth import exceptions |
| 41 | from google.auth import impersonated_credentials |
| 42 | from google.oauth2 import sts |
| 43 | from google.oauth2 import utils |
| 44 | |
bojeil-google | f97499c | 2021-06-09 07:58:25 -0700 | [diff] [blame^] | 45 | # External account JSON type identifier. |
| 46 | _EXTERNAL_ACCOUNT_JSON_TYPE = "external_account" |
bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame] | 47 | # The token exchange grant_type used for exchanging credentials. |
| 48 | _STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" |
| 49 | # The token exchange requested_token_type. This is always an access_token. |
| 50 | _STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" |
| 51 | # Cloud resource manager URL used to retrieve project information. |
| 52 | _CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/" |
| 53 | |
| 54 | |
| 55 | @six.add_metaclass(abc.ABCMeta) |
| 56 | class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject): |
| 57 | """Base class for all external account credentials. |
| 58 | |
| 59 | This is used to instantiate Credentials for exchanging external account |
| 60 | credentials for Google access token and authorizing requests to Google APIs. |
| 61 | The base class implements the common logic for exchanging external account |
| 62 | credentials for Google access tokens. |
| 63 | """ |
| 64 | |
| 65 | def __init__( |
| 66 | self, |
| 67 | audience, |
| 68 | subject_token_type, |
| 69 | token_url, |
| 70 | credential_source, |
| 71 | service_account_impersonation_url=None, |
| 72 | client_id=None, |
| 73 | client_secret=None, |
| 74 | quota_project_id=None, |
| 75 | scopes=None, |
| 76 | default_scopes=None, |
| 77 | ): |
| 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. |
| 94 | Raises: |
| 95 | google.auth.exceptions.RefreshError: If the generateAccessToken |
| 96 | endpoint returned an error. |
| 97 | """ |
| 98 | super(Credentials, self).__init__() |
| 99 | self._audience = audience |
| 100 | self._subject_token_type = subject_token_type |
| 101 | self._token_url = token_url |
| 102 | self._credential_source = credential_source |
| 103 | self._service_account_impersonation_url = service_account_impersonation_url |
| 104 | self._client_id = client_id |
| 105 | self._client_secret = client_secret |
| 106 | self._quota_project_id = quota_project_id |
| 107 | self._scopes = scopes |
| 108 | self._default_scopes = default_scopes |
| 109 | |
| 110 | if self._client_id: |
| 111 | self._client_auth = utils.ClientAuthentication( |
| 112 | utils.ClientAuthType.basic, self._client_id, self._client_secret |
| 113 | ) |
| 114 | else: |
| 115 | self._client_auth = None |
| 116 | self._sts_client = sts.Client(self._token_url, self._client_auth) |
| 117 | |
| 118 | if self._service_account_impersonation_url: |
| 119 | self._impersonated_credentials = self._initialize_impersonated_credentials() |
| 120 | else: |
| 121 | self._impersonated_credentials = None |
| 122 | self._project_id = None |
| 123 | |
| 124 | @property |
bojeil-google | f97499c | 2021-06-09 07:58:25 -0700 | [diff] [blame^] | 125 | def info(self): |
| 126 | """Generates the dictionary representation of the current credentials. |
| 127 | |
| 128 | Returns: |
| 129 | Mapping: The dictionary representation of the credentials. This is the |
| 130 | reverse of "from_info" defined on the subclasses of this class. It is |
| 131 | useful for serializing the current credentials so it can deserialized |
| 132 | later. |
| 133 | """ |
| 134 | config_info = { |
| 135 | "type": _EXTERNAL_ACCOUNT_JSON_TYPE, |
| 136 | "audience": self._audience, |
| 137 | "subject_token_type": self._subject_token_type, |
| 138 | "token_url": self._token_url, |
| 139 | "service_account_impersonation_url": self._service_account_impersonation_url, |
| 140 | "credential_source": copy.deepcopy(self._credential_source), |
| 141 | "quota_project_id": self._quota_project_id, |
| 142 | "client_id": self._client_id, |
| 143 | "client_secret": self._client_secret, |
| 144 | } |
| 145 | # Remove None fields in the info dictionary. |
| 146 | for k, v in dict(config_info).items(): |
| 147 | if v is None: |
| 148 | del config_info[k] |
| 149 | |
| 150 | return config_info |
| 151 | |
| 152 | @property |
| 153 | def service_account_email(self): |
| 154 | """Returns the service account email if service account impersonation is used. |
| 155 | |
| 156 | Returns: |
| 157 | Optional[str]: The service account email if impersonation is used. Otherwise |
| 158 | None is returned. |
| 159 | """ |
| 160 | if self._service_account_impersonation_url: |
| 161 | # Parse email from URL. The formal looks as follows: |
| 162 | # https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken |
| 163 | url = self._service_account_impersonation_url |
| 164 | start_index = url.rfind("/") |
| 165 | end_index = url.find(":generateAccessToken") |
| 166 | if start_index != -1 and end_index != -1 and start_index < end_index: |
| 167 | start_index = start_index + 1 |
| 168 | return url[start_index:end_index] |
| 169 | return None |
| 170 | |
| 171 | @property |
| 172 | def is_user(self): |
| 173 | """Returns whether the credentials represent a user (True) or workload (False). |
| 174 | Workloads behave similarly to service accounts. Currently workloads will use |
| 175 | service account impersonation but will eventually not require impersonation. |
| 176 | As a result, this property is more reliable than the service account email |
| 177 | property in determining if the credentials represent a user or workload. |
| 178 | |
| 179 | Returns: |
| 180 | bool: True if the credentials represent a user. False if they represent a |
| 181 | workload. |
| 182 | """ |
| 183 | # If service account impersonation is used, the credentials will always represent a |
| 184 | # service account. |
| 185 | if self._service_account_impersonation_url: |
| 186 | return False |
| 187 | # Workforce pools representing users have the following audience format: |
| 188 | # //iam.googleapis.com/locations/$location/workforcePools/$poolId/providers/$providerId |
| 189 | p = re.compile(r"//iam\.googleapis\.com/locations/[^/]+/workforcePools/") |
| 190 | if p.match(self._audience): |
| 191 | return True |
| 192 | return False |
| 193 | |
| 194 | @property |
bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame] | 195 | def requires_scopes(self): |
| 196 | """Checks if the credentials requires scopes. |
| 197 | |
| 198 | Returns: |
| 199 | bool: True if there are no scopes set otherwise False. |
| 200 | """ |
| 201 | return not self._scopes and not self._default_scopes |
| 202 | |
| 203 | @property |
| 204 | def project_number(self): |
| 205 | """Optional[str]: The project number corresponding to the workload identity pool.""" |
| 206 | |
| 207 | # STS audience pattern: |
| 208 | # //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/... |
| 209 | components = self._audience.split("/") |
| 210 | try: |
| 211 | project_index = components.index("projects") |
| 212 | if project_index + 1 < len(components): |
| 213 | return components[project_index + 1] or None |
| 214 | except ValueError: |
| 215 | return None |
| 216 | |
| 217 | @_helpers.copy_docstring(credentials.Scoped) |
| 218 | def with_scopes(self, scopes, default_scopes=None): |
| 219 | return self.__class__( |
| 220 | audience=self._audience, |
| 221 | subject_token_type=self._subject_token_type, |
| 222 | token_url=self._token_url, |
| 223 | credential_source=self._credential_source, |
| 224 | service_account_impersonation_url=self._service_account_impersonation_url, |
| 225 | client_id=self._client_id, |
| 226 | client_secret=self._client_secret, |
| 227 | quota_project_id=self._quota_project_id, |
| 228 | scopes=scopes, |
| 229 | default_scopes=default_scopes, |
| 230 | ) |
| 231 | |
| 232 | @abc.abstractmethod |
| 233 | def retrieve_subject_token(self, request): |
| 234 | """Retrieves the subject token using the credential_source object. |
| 235 | |
| 236 | Args: |
| 237 | request (google.auth.transport.Request): A callable used to make |
| 238 | HTTP requests. |
| 239 | Returns: |
| 240 | str: The retrieved subject token. |
| 241 | """ |
| 242 | # pylint: disable=missing-raises-doc |
| 243 | # (pylint doesn't recognize that this is abstract) |
| 244 | raise NotImplementedError("retrieve_subject_token must be implemented") |
| 245 | |
| 246 | def get_project_id(self, request): |
| 247 | """Retrieves the project ID corresponding to the workload identity pool. |
| 248 | |
| 249 | When not determinable, None is returned. |
| 250 | |
| 251 | This is introduced to support the current pattern of using the Auth library: |
| 252 | |
| 253 | credentials, project_id = google.auth.default() |
| 254 | |
| 255 | The resource may not have permission (resourcemanager.projects.get) to |
| 256 | call this API or the required scopes may not be selected: |
| 257 | https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes |
| 258 | |
| 259 | Args: |
| 260 | request (google.auth.transport.Request): A callable used to make |
| 261 | HTTP requests. |
| 262 | Returns: |
| 263 | Optional[str]: The project ID corresponding to the workload identity pool |
| 264 | if determinable. |
| 265 | """ |
| 266 | if self._project_id: |
| 267 | # If already retrieved, return the cached project ID value. |
| 268 | return self._project_id |
| 269 | scopes = self._scopes if self._scopes is not None else self._default_scopes |
| 270 | # Scopes are required in order to retrieve a valid access token. |
| 271 | if self.project_number and scopes: |
| 272 | headers = {} |
| 273 | url = _CLOUD_RESOURCE_MANAGER + self.project_number |
| 274 | self.before_request(request, "GET", url, headers) |
| 275 | response = request(url=url, method="GET", headers=headers) |
| 276 | |
| 277 | response_body = ( |
| 278 | response.data.decode("utf-8") |
| 279 | if hasattr(response.data, "decode") |
| 280 | else response.data |
| 281 | ) |
| 282 | response_data = json.loads(response_body) |
| 283 | |
| 284 | if response.status == 200: |
| 285 | # Cache result as this field is immutable. |
| 286 | self._project_id = response_data.get("projectId") |
| 287 | return self._project_id |
| 288 | |
| 289 | return None |
| 290 | |
| 291 | @_helpers.copy_docstring(credentials.Credentials) |
| 292 | def refresh(self, request): |
| 293 | scopes = self._scopes if self._scopes is not None else self._default_scopes |
| 294 | if self._impersonated_credentials: |
| 295 | self._impersonated_credentials.refresh(request) |
| 296 | self.token = self._impersonated_credentials.token |
| 297 | self.expiry = self._impersonated_credentials.expiry |
| 298 | else: |
| 299 | now = _helpers.utcnow() |
| 300 | response_data = self._sts_client.exchange_token( |
| 301 | request=request, |
| 302 | grant_type=_STS_GRANT_TYPE, |
| 303 | subject_token=self.retrieve_subject_token(request), |
| 304 | subject_token_type=self._subject_token_type, |
| 305 | audience=self._audience, |
| 306 | scopes=scopes, |
| 307 | requested_token_type=_STS_REQUESTED_TOKEN_TYPE, |
| 308 | ) |
| 309 | self.token = response_data.get("access_token") |
| 310 | lifetime = datetime.timedelta(seconds=response_data.get("expires_in")) |
| 311 | self.expiry = now + lifetime |
| 312 | |
| 313 | @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) |
| 314 | def with_quota_project(self, quota_project_id): |
| 315 | # Return copy of instance with the provided quota project ID. |
| 316 | return self.__class__( |
| 317 | audience=self._audience, |
| 318 | subject_token_type=self._subject_token_type, |
| 319 | token_url=self._token_url, |
| 320 | credential_source=self._credential_source, |
| 321 | service_account_impersonation_url=self._service_account_impersonation_url, |
| 322 | client_id=self._client_id, |
| 323 | client_secret=self._client_secret, |
| 324 | quota_project_id=quota_project_id, |
| 325 | scopes=self._scopes, |
| 326 | default_scopes=self._default_scopes, |
| 327 | ) |
| 328 | |
| 329 | def _initialize_impersonated_credentials(self): |
| 330 | """Generates an impersonated credentials. |
| 331 | |
| 332 | For more details, see `projects.serviceAccounts.generateAccessToken`_. |
| 333 | |
| 334 | .. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken |
| 335 | |
| 336 | Returns: |
| 337 | impersonated_credentials.Credential: The impersonated credentials |
| 338 | object. |
| 339 | |
| 340 | Raises: |
| 341 | google.auth.exceptions.RefreshError: If the generateAccessToken |
| 342 | endpoint returned an error. |
| 343 | """ |
| 344 | # Return copy of instance with no service account impersonation. |
| 345 | source_credentials = self.__class__( |
| 346 | audience=self._audience, |
| 347 | subject_token_type=self._subject_token_type, |
| 348 | token_url=self._token_url, |
| 349 | credential_source=self._credential_source, |
| 350 | service_account_impersonation_url=None, |
| 351 | client_id=self._client_id, |
| 352 | client_secret=self._client_secret, |
| 353 | quota_project_id=self._quota_project_id, |
| 354 | scopes=self._scopes, |
| 355 | default_scopes=self._default_scopes, |
| 356 | ) |
| 357 | |
| 358 | # Determine target_principal. |
bojeil-google | f97499c | 2021-06-09 07:58:25 -0700 | [diff] [blame^] | 359 | target_principal = self.service_account_email |
| 360 | if not target_principal: |
bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame] | 361 | raise exceptions.RefreshError( |
| 362 | "Unable to determine target principal from service account impersonation URL." |
| 363 | ) |
| 364 | |
| 365 | scopes = self._scopes if self._scopes is not None else self._default_scopes |
| 366 | # Initialize and return impersonated credentials. |
| 367 | return impersonated_credentials.Credentials( |
| 368 | source_credentials=source_credentials, |
| 369 | target_principal=target_principal, |
| 370 | target_scopes=scopes, |
| 371 | quota_project_id=self._quota_project_id, |
| 372 | iam_endpoint_override=self._service_account_impersonation_url, |
| 373 | ) |