1# 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
51import datetime
52
53import six
54
55from google.auth import _helpers
56from google.auth import credentials
57from google.oauth2 import sts
58
59# The maximum number of access boundary rules a Credential Access Boundary can
60# contain.
61_MAX_ACCESS_BOUNDARY_RULES_COUNT = 10
62# 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"
71
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        """
226        if not isinstance(value, six.string_types):
227            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:
252            if not isinstance(available_permission, six.string_types):
253                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        """
355        if not isinstance(value, six.string_types):
356            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        """
378        if not isinstance(value, six.string_types) and value is not None:
379            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        """
401        if not isinstance(value, six.string_types) and value is not None:
402            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
423
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")
484        # 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
494
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        )
502