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