1# Copyright 2018 Google Inc. 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"""Google Cloud Impersonated credentials. 16 17This module provides authentication for applications where local credentials 18impersonates a remote service account using `IAM Credentials API`_. 19 20This class can be used to impersonate a service account as long as the original 21Credential object has the "Service Account Token Creator" role on the target 22service account. 23 24 .. _IAM Credentials API: 25 https://cloud.google.com/iam/credentials/reference/rest/ 26""" 27 28import base64 29import copy 30from datetime import datetime 31import json 32 33import six 34from six.moves import http_client 35 36from google.auth import _helpers 37from google.auth import credentials 38from google.auth import exceptions 39from google.auth import jwt 40from google.auth.transport.requests import AuthorizedSession 41 42_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds 43 44_IAM_SCOPE = ["https://www.googleapis.com/auth/iam"] 45 46_IAM_ENDPOINT = ( 47 "https://iamcredentials.googleapis.com/v1/projects/-" 48 + "/serviceAccounts/{}:generateAccessToken" 49) 50 51_IAM_SIGN_ENDPOINT = ( 52 "https://iamcredentials.googleapis.com/v1/projects/-" 53 + "/serviceAccounts/{}:signBlob" 54) 55 56_IAM_IDTOKEN_ENDPOINT = ( 57 "https://iamcredentials.googleapis.com/v1/" 58 + "projects/-/serviceAccounts/{}:generateIdToken" 59) 60 61_REFRESH_ERROR = "Unable to acquire impersonated credentials" 62 63_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds 64 65_DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token" 66 67 68def _make_iam_token_request( 69 request, principal, headers, body, iam_endpoint_override=None 70): 71 """Makes a request to the Google Cloud IAM service for an access token. 72 Args: 73 request (Request): The Request object to use. 74 principal (str): The principal to request an access token for. 75 headers (Mapping[str, str]): Map of headers to transmit. 76 body (Mapping[str, str]): JSON Payload body for the iamcredentials 77 API call. 78 iam_endpoint_override (Optiona[str]): The full IAM endpoint override 79 with the target_principal embedded. This is useful when supporting 80 impersonation with regional endpoints. 81 82 Raises: 83 google.auth.exceptions.TransportError: Raised if there is an underlying 84 HTTP connection error 85 google.auth.exceptions.RefreshError: Raised if the impersonated 86 credentials are not available. Common reasons are 87 `iamcredentials.googleapis.com` is not enabled or the 88 `Service Account Token Creator` is not assigned 89 """ 90 iam_endpoint = iam_endpoint_override or _IAM_ENDPOINT.format(principal) 91 92 body = json.dumps(body).encode("utf-8") 93 94 response = request(url=iam_endpoint, method="POST", headers=headers, body=body) 95 96 # support both string and bytes type response.data 97 response_body = ( 98 response.data.decode("utf-8") 99 if hasattr(response.data, "decode") 100 else response.data 101 ) 102 103 if response.status != http_client.OK: 104 exceptions.RefreshError(_REFRESH_ERROR, response_body) 105 106 try: 107 token_response = json.loads(response_body) 108 token = token_response["accessToken"] 109 expiry = datetime.strptime(token_response["expireTime"], "%Y-%m-%dT%H:%M:%SZ") 110 111 return token, expiry 112 113 except (KeyError, ValueError) as caught_exc: 114 new_exc = exceptions.RefreshError( 115 "{}: No access token or invalid expiration in response.".format( 116 _REFRESH_ERROR 117 ), 118 response_body, 119 ) 120 six.raise_from(new_exc, caught_exc) 121 122 123class Credentials(credentials.CredentialsWithQuotaProject, credentials.Signing): 124 """This module defines impersonated credentials which are essentially 125 impersonated identities. 126 127 Impersonated Credentials allows credentials issued to a user or 128 service account to impersonate another. The target service account must 129 grant the originating credential principal the 130 `Service Account Token Creator`_ IAM role: 131 132 For more information about Token Creator IAM role and 133 IAMCredentials API, see 134 `Creating Short-Lived Service Account Credentials`_. 135 136 .. _Service Account Token Creator: 137 https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role 138 139 .. _Creating Short-Lived Service Account Credentials: 140 https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials 141 142 Usage: 143 144 First grant source_credentials the `Service Account Token Creator` 145 role on the target account to impersonate. In this example, the 146 service account represented by svc_account.json has the 147 token creator role on 148 `impersonated-account@_project_.iam.gserviceaccount.com`. 149 150 Enable the IAMCredentials API on the source project: 151 `gcloud services enable iamcredentials.googleapis.com`. 152 153 Initialize a source credential which does not have access to 154 list bucket:: 155 156 from google.oauth2 import service_account 157 158 target_scopes = [ 159 'https://www.googleapis.com/auth/devstorage.read_only'] 160 161 source_credentials = ( 162 service_account.Credentials.from_service_account_file( 163 '/path/to/svc_account.json', 164 scopes=target_scopes)) 165 166 Now use the source credentials to acquire credentials to impersonate 167 another service account:: 168 169 from google.auth import impersonated_credentials 170 171 target_credentials = impersonated_credentials.Credentials( 172 source_credentials=source_credentials, 173 target_principal='impersonated-account@_project_.iam.gserviceaccount.com', 174 target_scopes = target_scopes, 175 lifetime=500) 176 177 Resource access is granted:: 178 179 client = storage.Client(credentials=target_credentials) 180 buckets = client.list_buckets(project='your_project') 181 for bucket in buckets: 182 print(bucket.name) 183 """ 184 185 def __init__( 186 self, 187 source_credentials, 188 target_principal, 189 target_scopes, 190 delegates=None, 191 lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, 192 quota_project_id=None, 193 iam_endpoint_override=None, 194 ): 195 """ 196 Args: 197 source_credentials (google.auth.Credentials): The source credential 198 used as to acquire the impersonated credentials. 199 target_principal (str): The service account to impersonate. 200 target_scopes (Sequence[str]): Scopes to request during the 201 authorization grant. 202 delegates (Sequence[str]): The chained list of delegates required 203 to grant the final access_token. If set, the sequence of 204 identities must have "Service Account Token Creator" capability 205 granted to the prceeding identity. For example, if set to 206 [serviceAccountB, serviceAccountC], the source_credential 207 must have the Token Creator role on serviceAccountB. 208 serviceAccountB must have the Token Creator on 209 serviceAccountC. 210 Finally, C must have Token Creator on target_principal. 211 If left unset, source_credential must have that role on 212 target_principal. 213 lifetime (int): Number of seconds the delegated credential should 214 be valid for (upto 3600). 215 quota_project_id (Optional[str]): The project ID used for quota and billing. 216 This project may be different from the project used to 217 create the credentials. 218 iam_endpoint_override (Optiona[str]): The full IAM endpoint override 219 with the target_principal embedded. This is useful when supporting 220 impersonation with regional endpoints. 221 """ 222 223 super(Credentials, self).__init__() 224 225 self._source_credentials = copy.copy(source_credentials) 226 # Service account source credentials must have the _IAM_SCOPE 227 # added to refresh correctly. User credentials cannot have 228 # their original scopes modified. 229 if isinstance(self._source_credentials, credentials.Scoped): 230 self._source_credentials = self._source_credentials.with_scopes(_IAM_SCOPE) 231 self._target_principal = target_principal 232 self._target_scopes = target_scopes 233 self._delegates = delegates 234 self._lifetime = lifetime 235 self.token = None 236 self.expiry = _helpers.utcnow() 237 self._quota_project_id = quota_project_id 238 self._iam_endpoint_override = iam_endpoint_override 239 240 @_helpers.copy_docstring(credentials.Credentials) 241 def refresh(self, request): 242 self._update_token(request) 243 244 def _update_token(self, request): 245 """Updates credentials with a new access_token representing 246 the impersonated account. 247 248 Args: 249 request (google.auth.transport.requests.Request): Request object 250 to use for refreshing credentials. 251 """ 252 253 # Refresh our source credentials if it is not valid. 254 if not self._source_credentials.valid: 255 self._source_credentials.refresh(request) 256 257 body = { 258 "delegates": self._delegates, 259 "scope": self._target_scopes, 260 "lifetime": str(self._lifetime) + "s", 261 } 262 263 headers = {"Content-Type": "application/json"} 264 265 # Apply the source credentials authentication info. 266 self._source_credentials.apply(headers) 267 268 self.token, self.expiry = _make_iam_token_request( 269 request=request, 270 principal=self._target_principal, 271 headers=headers, 272 body=body, 273 iam_endpoint_override=self._iam_endpoint_override, 274 ) 275 276 def sign_bytes(self, message): 277 278 iam_sign_endpoint = _IAM_SIGN_ENDPOINT.format(self._target_principal) 279 280 body = { 281 "payload": base64.b64encode(message).decode("utf-8"), 282 "delegates": self._delegates, 283 } 284 285 headers = {"Content-Type": "application/json"} 286 287 authed_session = AuthorizedSession(self._source_credentials) 288 289 response = authed_session.post( 290 url=iam_sign_endpoint, headers=headers, json=body 291 ) 292 293 if response.status_code != http_client.OK: 294 raise exceptions.TransportError( 295 "Error calling sign_bytes: {}".format(response.json()) 296 ) 297 298 return base64.b64decode(response.json()["signedBlob"]) 299 300 @property 301 def signer_email(self): 302 return self._target_principal 303 304 @property 305 def service_account_email(self): 306 return self._target_principal 307 308 @property 309 def signer(self): 310 return self 311 312 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) 313 def with_quota_project(self, quota_project_id): 314 return self.__class__( 315 self._source_credentials, 316 target_principal=self._target_principal, 317 target_scopes=self._target_scopes, 318 delegates=self._delegates, 319 lifetime=self._lifetime, 320 quota_project_id=quota_project_id, 321 iam_endpoint_override=self._iam_endpoint_override, 322 ) 323 324 325class IDTokenCredentials(credentials.CredentialsWithQuotaProject): 326 """Open ID Connect ID Token-based service account credentials. 327 328 """ 329 330 def __init__( 331 self, 332 target_credentials, 333 target_audience=None, 334 include_email=False, 335 quota_project_id=None, 336 ): 337 """ 338 Args: 339 target_credentials (google.auth.Credentials): The target 340 credential used as to acquire the id tokens for. 341 target_audience (string): Audience to issue the token for. 342 include_email (bool): Include email in IdToken 343 quota_project_id (Optional[str]): The project ID used for 344 quota and billing. 345 """ 346 super(IDTokenCredentials, self).__init__() 347 348 if not isinstance(target_credentials, Credentials): 349 raise exceptions.GoogleAuthError( 350 "Provided Credential must be " "impersonated_credentials" 351 ) 352 self._target_credentials = target_credentials 353 self._target_audience = target_audience 354 self._include_email = include_email 355 self._quota_project_id = quota_project_id 356 357 def from_credentials(self, target_credentials, target_audience=None): 358 return self.__class__( 359 target_credentials=self._target_credentials, 360 target_audience=target_audience, 361 include_email=self._include_email, 362 quota_project_id=self._quota_project_id, 363 ) 364 365 def with_target_audience(self, target_audience): 366 return self.__class__( 367 target_credentials=self._target_credentials, 368 target_audience=target_audience, 369 include_email=self._include_email, 370 quota_project_id=self._quota_project_id, 371 ) 372 373 def with_include_email(self, include_email): 374 return self.__class__( 375 target_credentials=self._target_credentials, 376 target_audience=self._target_audience, 377 include_email=include_email, 378 quota_project_id=self._quota_project_id, 379 ) 380 381 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) 382 def with_quota_project(self, quota_project_id): 383 return self.__class__( 384 target_credentials=self._target_credentials, 385 target_audience=self._target_audience, 386 include_email=self._include_email, 387 quota_project_id=quota_project_id, 388 ) 389 390 @_helpers.copy_docstring(credentials.Credentials) 391 def refresh(self, request): 392 393 iam_sign_endpoint = _IAM_IDTOKEN_ENDPOINT.format( 394 self._target_credentials.signer_email 395 ) 396 397 body = { 398 "audience": self._target_audience, 399 "delegates": self._target_credentials._delegates, 400 "includeEmail": self._include_email, 401 } 402 403 headers = {"Content-Type": "application/json"} 404 405 authed_session = AuthorizedSession( 406 self._target_credentials._source_credentials, auth_request=request 407 ) 408 409 response = authed_session.post( 410 url=iam_sign_endpoint, 411 headers=headers, 412 data=json.dumps(body).encode("utf-8"), 413 ) 414 415 id_token = response.json()["token"] 416 self.token = id_token 417 self.expiry = datetime.fromtimestamp(jwt.decode(id_token, verify=False)["exp"]) 418