blob: a1d7b6e46e39c8256dd71eb391d020f513a4fc1c [file] [log] [blame]
bojeil-googled8839212021-07-08 10:56:22 -07001# Copyright 2021 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"""Downscoping with Credential Access Boundaries
16
17This module provides the ability to downscope credentials using
18`Downscoping with Credential Access Boundaries`_. This is useful to restrict the
19Identity and Access Management (IAM) permissions that a short-lived credential
20can use.
21
22To downscope permissions of a source credential, a Credential Access Boundary
23that specifies which resources the new credential can access, as well as
24an upper bound on the permissions that are available on each resource, has to
25be defined. A downscoped credential can then be instantiated using the source
26credential and the Credential Access Boundary.
27
28The common pattern of usage is to have a token broker with elevated access
29generate these downscoped credentials from higher access source credentials and
30pass the downscoped short-lived access tokens to a token consumer via some
31secure authenticated channel for limited access to Google Cloud Storage
32resources.
33
34For example, a token broker can be set up on a server in a private network.
35Various workloads (token consumers) in the same network will send authenticated
36requests to that broker for downscoped tokens to access or modify specific google
37cloud storage buckets.
38
39The broker will instantiate downscoped credentials instances that can be used to
40generate short lived downscoped access tokens that can be passed to the token
41consumer. These downscoped access tokens can be injected by the consumer into
42google.oauth2.Credentials and used to initialize a storage client instance to
43access Google Cloud Storage resources with restricted access.
44
45Note: Only Cloud Storage supports Credential Access Boundaries. Other Google
46Cloud services do not support this feature.
47
48.. _Downscoping with Credential Access Boundaries: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials
49"""
50
bojeil-google2f5c3a62021-07-09 13:27:02 -070051import datetime
52
arithmetic17285bd5ccf2021-10-21 15:25:46 -070053import six
54
bojeil-google2f5c3a62021-07-09 13:27:02 -070055from google.auth import _helpers
56from google.auth import credentials
57from google.oauth2 import sts
58
bojeil-googled8839212021-07-08 10:56:22 -070059# The maximum number of access boundary rules a Credential Access Boundary can
60# contain.
61_MAX_ACCESS_BOUNDARY_RULES_COUNT = 10
bojeil-google2f5c3a62021-07-09 13:27:02 -070062# The token exchange grant_type used for exchanging credentials.
63_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
64# The token exchange requested_token_type. This is always an access_token.
65_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
66# The STS token URL used to exchanged a short lived access token for a downscoped one.
67_STS_TOKEN_URL = "https://sts.googleapis.com/v1/token"
68# The subject token type to use when exchanging a short lived access token for a
69# downscoped token.
70_STS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
bojeil-googled8839212021-07-08 10:56:22 -070071
72
73class CredentialAccessBoundary(object):
74 """Defines a Credential Access Boundary which contains a list of access boundary
75 rules. Each rule contains information on the resource that the rule applies to,
76 the upper bound of the permissions that are available on that resource and an
77 optional condition to further restrict permissions.
78 """
79
80 def __init__(self, rules=[]):
81 """Instantiates a Credential Access Boundary. A Credential Access Boundary
82 can contain up to 10 access boundary rules.
83
84 Args:
85 rules (Sequence[google.auth.downscoped.AccessBoundaryRule]): The list of
86 access boundary rules limiting the access that a downscoped credential
87 will have.
88 Raises:
89 TypeError: If any of the rules are not a valid type.
90 ValueError: If the provided rules exceed the maximum allowed.
91 """
92 self.rules = rules
93
94 @property
95 def rules(self):
96 """Returns the list of access boundary rules defined on the Credential
97 Access Boundary.
98
99 Returns:
100 Tuple[google.auth.downscoped.AccessBoundaryRule, ...]: The list of access
101 boundary rules defined on the Credential Access Boundary. These are returned
102 as an immutable tuple to prevent modification.
103 """
104 return tuple(self._rules)
105
106 @rules.setter
107 def rules(self, value):
108 """Updates the current rules on the Credential Access Boundary. This will overwrite
109 the existing set of rules.
110
111 Args:
112 value (Sequence[google.auth.downscoped.AccessBoundaryRule]): The list of
113 access boundary rules limiting the access that a downscoped credential
114 will have.
115 Raises:
116 TypeError: If any of the rules are not a valid type.
117 ValueError: If the provided rules exceed the maximum allowed.
118 """
119 if len(value) > _MAX_ACCESS_BOUNDARY_RULES_COUNT:
120 raise ValueError(
121 "Credential access boundary rules can have a maximum of {} rules.".format(
122 _MAX_ACCESS_BOUNDARY_RULES_COUNT
123 )
124 )
125 for access_boundary_rule in value:
126 if not isinstance(access_boundary_rule, AccessBoundaryRule):
127 raise TypeError(
128 "List of rules provided do not contain a valid 'google.auth.downscoped.AccessBoundaryRule'."
129 )
130 # Make a copy of the original list.
131 self._rules = list(value)
132
133 def add_rule(self, rule):
134 """Adds a single access boundary rule to the existing rules.
135
136 Args:
137 rule (google.auth.downscoped.AccessBoundaryRule): The access boundary rule,
138 limiting the access that a downscoped credential will have, to be added to
139 the existing rules.
140 Raises:
141 TypeError: If any of the rules are not a valid type.
142 ValueError: If the provided rules exceed the maximum allowed.
143 """
144 if len(self.rules) == _MAX_ACCESS_BOUNDARY_RULES_COUNT:
145 raise ValueError(
146 "Credential access boundary rules can have a maximum of {} rules.".format(
147 _MAX_ACCESS_BOUNDARY_RULES_COUNT
148 )
149 )
150 if not isinstance(rule, AccessBoundaryRule):
151 raise TypeError(
152 "The provided rule does not contain a valid 'google.auth.downscoped.AccessBoundaryRule'."
153 )
154 self._rules.append(rule)
155
156 def to_json(self):
157 """Generates the dictionary representation of the Credential Access Boundary.
158 This uses the format expected by the Security Token Service API as documented in
159 `Defining a Credential Access Boundary`_.
160
161 .. _Defining a Credential Access Boundary:
162 https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary
163
164 Returns:
165 Mapping: Credential Access Boundary Rule represented in a dictionary object.
166 """
167 rules = []
168 for access_boundary_rule in self.rules:
169 rules.append(access_boundary_rule.to_json())
170
171 return {"accessBoundary": {"accessBoundaryRules": rules}}
172
173
174class AccessBoundaryRule(object):
175 """Defines an access boundary rule which contains information on the resource that
176 the rule applies to, the upper bound of the permissions that are available on that
177 resource and an optional condition to further restrict permissions.
178 """
179
180 def __init__(
181 self, available_resource, available_permissions, availability_condition=None
182 ):
183 """Instantiates a single access boundary rule.
184
185 Args:
186 available_resource (str): The full resource name of the Cloud Storage bucket
187 that the rule applies to. Use the format
188 "//storage.googleapis.com/projects/_/buckets/bucket-name".
189 available_permissions (Sequence[str]): A list defining the upper bound that
190 the downscoped token will have on the available permissions for the
191 resource. Each value is the identifier for an IAM predefined role or
192 custom role, with the prefix "inRole:". For example:
193 "inRole:roles/storage.objectViewer".
194 Only the permissions in these roles will be available.
195 availability_condition (Optional[google.auth.downscoped.AvailabilityCondition]):
196 Optional condition that restricts the availability of permissions to
197 specific Cloud Storage objects.
198
199 Raises:
200 TypeError: If any of the parameters are not of the expected types.
201 ValueError: If any of the parameters are not of the expected values.
202 """
203 self.available_resource = available_resource
204 self.available_permissions = available_permissions
205 self.availability_condition = availability_condition
206
207 @property
208 def available_resource(self):
209 """Returns the current available resource.
210
211 Returns:
212 str: The current available resource.
213 """
214 return self._available_resource
215
216 @available_resource.setter
217 def available_resource(self, value):
218 """Updates the current available resource.
219
220 Args:
221 value (str): The updated value of the available resource.
222
223 Raises:
224 TypeError: If the value is not a string.
225 """
arithmetic17285bd5ccf2021-10-21 15:25:46 -0700226 if not isinstance(value, six.string_types):
bojeil-googled8839212021-07-08 10:56:22 -0700227 raise TypeError("The provided available_resource is not a string.")
228 self._available_resource = value
229
230 @property
231 def available_permissions(self):
232 """Returns the current available permissions.
233
234 Returns:
235 Tuple[str, ...]: The current available permissions. These are returned
236 as an immutable tuple to prevent modification.
237 """
238 return tuple(self._available_permissions)
239
240 @available_permissions.setter
241 def available_permissions(self, value):
242 """Updates the current available permissions.
243
244 Args:
245 value (Sequence[str]): The updated value of the available permissions.
246
247 Raises:
248 TypeError: If the value is not a list of strings.
249 ValueError: If the value is not valid.
250 """
251 for available_permission in value:
arithmetic17285bd5ccf2021-10-21 15:25:46 -0700252 if not isinstance(available_permission, six.string_types):
bojeil-googled8839212021-07-08 10:56:22 -0700253 raise TypeError(
254 "Provided available_permissions are not a list of strings."
255 )
256 if available_permission.find("inRole:") != 0:
257 raise ValueError(
258 "available_permissions must be prefixed with 'inRole:'."
259 )
260 # Make a copy of the original list.
261 self._available_permissions = list(value)
262
263 @property
264 def availability_condition(self):
265 """Returns the current availability condition.
266
267 Returns:
268 Optional[google.auth.downscoped.AvailabilityCondition]: The current
269 availability condition.
270 """
271 return self._availability_condition
272
273 @availability_condition.setter
274 def availability_condition(self, value):
275 """Updates the current availability condition.
276
277 Args:
278 value (Optional[google.auth.downscoped.AvailabilityCondition]): The updated
279 value of the availability condition.
280
281 Raises:
282 TypeError: If the value is not of type google.auth.downscoped.AvailabilityCondition
283 or None.
284 """
285 if not isinstance(value, AvailabilityCondition) and value is not None:
286 raise TypeError(
287 "The provided availability_condition is not a 'google.auth.downscoped.AvailabilityCondition' or None."
288 )
289 self._availability_condition = value
290
291 def to_json(self):
292 """Generates the dictionary representation of the access boundary rule.
293 This uses the format expected by the Security Token Service API as documented in
294 `Defining a Credential Access Boundary`_.
295
296 .. _Defining a Credential Access Boundary:
297 https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary
298
299 Returns:
300 Mapping: The access boundary rule represented in a dictionary object.
301 """
302 json = {
303 "availablePermissions": list(self.available_permissions),
304 "availableResource": self.available_resource,
305 }
306 if self.availability_condition:
307 json["availabilityCondition"] = self.availability_condition.to_json()
308 return json
309
310
311class AvailabilityCondition(object):
312 """An optional condition that can be used as part of a Credential Access Boundary
313 to further restrict permissions."""
314
315 def __init__(self, expression, title=None, description=None):
316 """Instantiates an availability condition using the provided expression and
317 optional title or description.
318
319 Args:
320 expression (str): A condition expression that specifies the Cloud Storage
321 objects where permissions are available. For example, this expression
322 makes permissions available for objects whose name starts with "customer-a":
323 "resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-a')"
324 title (Optional[str]): An optional short string that identifies the purpose of
325 the condition.
326 description (Optional[str]): Optional details about the purpose of the condition.
327
328 Raises:
329 TypeError: If any of the parameters are not of the expected types.
330 ValueError: If any of the parameters are not of the expected values.
331 """
332 self.expression = expression
333 self.title = title
334 self.description = description
335
336 @property
337 def expression(self):
338 """Returns the current condition expression.
339
340 Returns:
341 str: The current conditon expression.
342 """
343 return self._expression
344
345 @expression.setter
346 def expression(self, value):
347 """Updates the current condition expression.
348
349 Args:
350 value (str): The updated value of the condition expression.
351
352 Raises:
353 TypeError: If the value is not of type string.
354 """
arithmetic17285bd5ccf2021-10-21 15:25:46 -0700355 if not isinstance(value, six.string_types):
bojeil-googled8839212021-07-08 10:56:22 -0700356 raise TypeError("The provided expression is not a string.")
357 self._expression = value
358
359 @property
360 def title(self):
361 """Returns the current title.
362
363 Returns:
364 Optional[str]: The current title.
365 """
366 return self._title
367
368 @title.setter
369 def title(self, value):
370 """Updates the current title.
371
372 Args:
373 value (Optional[str]): The updated value of the title.
374
375 Raises:
376 TypeError: If the value is not of type string or None.
377 """
arithmetic17285bd5ccf2021-10-21 15:25:46 -0700378 if not isinstance(value, six.string_types) and value is not None:
bojeil-googled8839212021-07-08 10:56:22 -0700379 raise TypeError("The provided title is not a string or None.")
380 self._title = value
381
382 @property
383 def description(self):
384 """Returns the current description.
385
386 Returns:
387 Optional[str]: The current description.
388 """
389 return self._description
390
391 @description.setter
392 def description(self, value):
393 """Updates the current description.
394
395 Args:
396 value (Optional[str]): The updated value of the description.
397
398 Raises:
399 TypeError: If the value is not of type string or None.
400 """
arithmetic17285bd5ccf2021-10-21 15:25:46 -0700401 if not isinstance(value, six.string_types) and value is not None:
bojeil-googled8839212021-07-08 10:56:22 -0700402 raise TypeError("The provided description is not a string or None.")
403 self._description = value
404
405 def to_json(self):
406 """Generates the dictionary representation of the availability condition.
407 This uses the format expected by the Security Token Service API as documented in
408 `Defining a Credential Access Boundary`_.
409
410 .. _Defining a Credential Access Boundary:
411 https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary
412
413 Returns:
414 Mapping[str, str]: The availability condition represented in a dictionary
415 object.
416 """
417 json = {"expression": self.expression}
418 if self.title:
419 json["title"] = self.title
420 if self.description:
421 json["description"] = self.description
422 return json
bojeil-google2f5c3a62021-07-09 13:27:02 -0700423
424
425class Credentials(credentials.CredentialsWithQuotaProject):
426 """Defines a set of Google credentials that are downscoped from an existing set
427 of Google OAuth2 credentials. This is useful to restrict the Identity and Access
428 Management (IAM) permissions that a short-lived credential can use.
429 The common pattern of usage is to have a token broker with elevated access
430 generate these downscoped credentials from higher access source credentials and
431 pass the downscoped short-lived access tokens to a token consumer via some
432 secure authenticated channel for limited access to Google Cloud Storage
433 resources.
434 """
435
436 def __init__(
437 self, source_credentials, credential_access_boundary, quota_project_id=None
438 ):
439 """Instantiates a downscoped credentials object using the provided source
440 credentials and credential access boundary rules.
441 To downscope permissions of a source credential, a Credential Access Boundary
442 that specifies which resources the new credential can access, as well as an
443 upper bound on the permissions that are available on each resource, has to be
444 defined. A downscoped credential can then be instantiated using the source
445 credential and the Credential Access Boundary.
446
447 Args:
448 source_credentials (google.auth.credentials.Credentials): The source credentials
449 to be downscoped based on the provided Credential Access Boundary rules.
450 credential_access_boundary (google.auth.downscoped.CredentialAccessBoundary):
451 The Credential Access Boundary which contains a list of access boundary
452 rules. Each rule contains information on the resource that the rule applies to,
453 the upper bound of the permissions that are available on that resource and an
454 optional condition to further restrict permissions.
455 quota_project_id (Optional[str]): The optional quota project ID.
456 Raises:
457 google.auth.exceptions.RefreshError: If the source credentials
458 return an error on token refresh.
459 google.auth.exceptions.OAuthError: If the STS token exchange
460 endpoint returned an error during downscoped token generation.
461 """
462
463 super(Credentials, self).__init__()
464 self._source_credentials = source_credentials
465 self._credential_access_boundary = credential_access_boundary
466 self._quota_project_id = quota_project_id
467 self._sts_client = sts.Client(_STS_TOKEN_URL)
468
469 @_helpers.copy_docstring(credentials.Credentials)
470 def refresh(self, request):
471 # Generate an access token from the source credentials.
472 self._source_credentials.refresh(request)
473 now = _helpers.utcnow()
474 # Exchange the access token for a downscoped access token.
475 response_data = self._sts_client.exchange_token(
476 request=request,
477 grant_type=_STS_GRANT_TYPE,
478 subject_token=self._source_credentials.token,
479 subject_token_type=_STS_SUBJECT_TOKEN_TYPE,
480 requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
481 additional_options=self._credential_access_boundary.to_json(),
482 )
483 self.token = response_data.get("access_token")
bojeil-googledfad6612021-07-20 10:43:13 -0700484 # For downscoping CAB flow, the STS endpoint may not return the expiration
485 # field for some flows. The generated downscoped token should always have
486 # the same expiration time as the source credentials. When no expires_in
487 # field is returned in the response, we can just get the expiration time
488 # from the source credentials.
489 if response_data.get("expires_in"):
490 lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
491 self.expiry = now + lifetime
492 else:
493 self.expiry = self._source_credentials.expiry
bojeil-google2f5c3a62021-07-09 13:27:02 -0700494
495 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
496 def with_quota_project(self, quota_project_id):
497 return self.__class__(
498 self._source_credentials,
499 self._credential_access_boundary,
500 quota_project_id=quota_project_id,
501 )