impersonated_credentials.py 21 KB

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