impersonated_credentials.py 17 KB

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