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