impersonated_credentials.py 15 KB

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