OLD | NEW |
(Empty) | |
| 1 # Copyright 2013 Google Inc. All Rights Reserved. |
| 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 This module provides the chacl command to gsutil. |
| 16 |
| 17 This command allows users to easily specify changes to access control lists. |
| 18 """ |
| 19 |
| 20 import random |
| 21 import re |
| 22 import time |
| 23 from xml.dom import minidom |
| 24 from boto.exception import GSResponseError |
| 25 from boto.gs import acl |
| 26 from gslib import name_expansion |
| 27 from gslib.command import Command |
| 28 from gslib.command import COMMAND_NAME |
| 29 from gslib.command import COMMAND_NAME_ALIASES |
| 30 from gslib.command import CONFIG_REQUIRED |
| 31 from gslib.command import FILE_URIS_OK |
| 32 from gslib.command import MAX_ARGS |
| 33 from gslib.command import MIN_ARGS |
| 34 from gslib.command import PROVIDER_URIS_OK |
| 35 from gslib.command import SUPPORTED_SUB_ARGS |
| 36 from gslib.command import URIS_START_ARG |
| 37 from gslib.exception import CommandException |
| 38 from gslib.help_provider import HELP_NAME |
| 39 from gslib.help_provider import HELP_NAME_ALIASES |
| 40 from gslib.help_provider import HELP_ONE_LINE_SUMMARY |
| 41 from gslib.help_provider import HELP_TEXT |
| 42 from gslib.help_provider import HELP_TYPE |
| 43 from gslib.help_provider import HelpType |
| 44 from gslib.util import NO_MAX |
| 45 from gslib.util import Retry |
| 46 |
| 47 |
| 48 class ChangeType(object): |
| 49 USER = 'User' |
| 50 GROUP = 'Group' |
| 51 |
| 52 |
| 53 class AclChange(object): |
| 54 """Represents a logical change to an access control list.""" |
| 55 public_scopes = ['AllAuthenticatedUsers', 'AllUsers'] |
| 56 id_scopes = ['UserById', 'GroupById'] |
| 57 email_scopes = ['UserByEmail', 'GroupByEmail'] |
| 58 domain_scopes = ['GroupByDomain'] |
| 59 scope_types = public_scopes + id_scopes + email_scopes + domain_scopes |
| 60 |
| 61 permission_shorthand_mapping = { |
| 62 'R': 'READ', |
| 63 'W': 'WRITE', |
| 64 'FC': 'FULL_CONTROL', |
| 65 } |
| 66 |
| 67 def __init__(self, acl_change_descriptor, scope_type, logger): |
| 68 """Creates an AclChange object. |
| 69 |
| 70 acl_change_descriptor: An acl change as described in chacl help. |
| 71 scope_type: Either ChangeType.USER or ChangeType.GROUP, specifying the |
| 72 extent of the scope. |
| 73 logger: An instance of ThreadedLogger. |
| 74 """ |
| 75 self.logger = logger |
| 76 self.identifier = '' |
| 77 |
| 78 self.raw_descriptor = acl_change_descriptor |
| 79 self._Parse(acl_change_descriptor, scope_type) |
| 80 self._Validate() |
| 81 |
| 82 def __str__(self): |
| 83 return 'AclChange<{0}|{1}|{2}>'.format(self.scope_type, self.perm, |
| 84 self.identifier) |
| 85 |
| 86 def _Parse(self, change_descriptor, scope_type): |
| 87 """Parses an ACL Change descriptor.""" |
| 88 |
| 89 def _ClassifyScopeIdentifier(text): |
| 90 re_map = { |
| 91 'AllAuthenticatedUsers': r'^(AllAuthenticatedUsers|AllAuth)$', |
| 92 'AllUsers': '^(AllUsers|All)$', |
| 93 'Email': r'^.+@.+\..+$', |
| 94 'Id': r'^[0-9A-Fa-f]{64}$', |
| 95 'Domain': r'^[^@]+\..+$', |
| 96 } |
| 97 for type_string, regex in re_map.items(): |
| 98 if re.match(regex, text, re.IGNORECASE): |
| 99 return type_string |
| 100 |
| 101 if change_descriptor.count(':') != 1: |
| 102 raise CommandException('{0} is an invalid change description.' |
| 103 .format(change_descriptor)) |
| 104 |
| 105 scope_string, perm_token = change_descriptor.split(':') |
| 106 |
| 107 perm_token = perm_token.upper() |
| 108 if perm_token in self.permission_shorthand_mapping: |
| 109 self.perm = self.permission_shorthand_mapping[perm_token] |
| 110 else: |
| 111 self.perm = perm_token |
| 112 |
| 113 scope_class = _ClassifyScopeIdentifier(scope_string) |
| 114 if scope_class == 'Domain': |
| 115 # This may produce an invalid UserByDomain scope, |
| 116 # which is good because then validate can complain. |
| 117 self.scope_type = '{0}ByDomain'.format(scope_type) |
| 118 self.identifier = scope_string |
| 119 elif scope_class in ['Email', 'Id']: |
| 120 self.scope_type = '{0}By{1}'.format(scope_type, scope_class) |
| 121 self.identifier = scope_string |
| 122 elif scope_class == 'AllAuthenticatedUsers': |
| 123 self.scope_type = 'AllAuthenticatedUsers' |
| 124 elif scope_class == 'AllUsers': |
| 125 self.scope_type = 'AllUsers' |
| 126 else: |
| 127 # This is just a fallback, so we set it to something |
| 128 # and the validate step has something to go on. |
| 129 self.scope_type = scope_string |
| 130 |
| 131 def _Validate(self): |
| 132 """Validates a parsed AclChange object.""" |
| 133 |
| 134 def _ThrowError(msg): |
| 135 raise CommandException('{0} is not a valid ACL change\n{1}' |
| 136 .format(self.raw_descriptor, msg)) |
| 137 |
| 138 if self.scope_type not in self.scope_types: |
| 139 _ThrowError('{0} is not a valid scope type'.format(self.scope_type)) |
| 140 |
| 141 if self.scope_type in self.public_scopes and self.identifier: |
| 142 _ThrowError('{0} requires no arguments'.format(self.scope_type)) |
| 143 |
| 144 if self.scope_type in self.id_scopes and not self.identifier: |
| 145 _ThrowError('{0} requires an id'.format(self.scope_type)) |
| 146 |
| 147 if self.scope_type in self.email_scopes and not self.identifier: |
| 148 _ThrowError('{0} requires an email address'.format(self.scope_type)) |
| 149 |
| 150 if self.scope_type in self.domain_scopes and not self.identifier: |
| 151 _ThrowError('{0} requires domain'.format(self.scope_type)) |
| 152 |
| 153 if self.perm not in self.permission_shorthand_mapping.values(): |
| 154 perms = ', '.join(self.permission_shorthand_mapping.values()) |
| 155 _ThrowError('Allowed permissions are {0}'.format(perms)) |
| 156 |
| 157 def _YieldMatchingEntries(self, current_acl): |
| 158 """Generator that yields entries that match the change descriptor. |
| 159 |
| 160 current_acl: An instance of bogo.gs.acl.ACL which will be searched |
| 161 for matching entries. |
| 162 """ |
| 163 for entry in current_acl.entries.entry_list: |
| 164 if entry.scope.type == self.scope_type: |
| 165 if self.scope_type in ['UserById', 'GroupById']: |
| 166 if self.identifier == entry.scope.id: |
| 167 yield entry |
| 168 elif self.scope_type in ['UserByEmail', 'GroupByEmail']: |
| 169 if self.identifier == entry.scope.email_address: |
| 170 yield entry |
| 171 elif self.scope_type == 'GroupByDomain': |
| 172 if self.identifier == entry.scope.domain: |
| 173 yield entry |
| 174 elif self.scope_type in ['AllUsers', 'AllAuthenticatedUsers']: |
| 175 yield entry |
| 176 else: |
| 177 raise CommandException('Found an unrecognized ACL ' |
| 178 'entry type, aborting.') |
| 179 |
| 180 def _AddEntry(self, current_acl): |
| 181 """Adds an entry to an ACL.""" |
| 182 if self.scope_type in ['UserById', 'UserById', 'GroupById']: |
| 183 entry = acl.Entry(type=self.scope_type, permission=self.perm, |
| 184 id=self.identifier) |
| 185 elif self.scope_type in ['UserByEmail', 'GroupByEmail']: |
| 186 entry = acl.Entry(type=self.scope_type, permission=self.perm, |
| 187 email_address=self.identifier) |
| 188 elif self.scope_type == 'GroupByDomain': |
| 189 entry = acl.Entry(type=self.scope_type, permission=self.perm, |
| 190 domain=self.identifier) |
| 191 else: |
| 192 entry = acl.Entry(type=self.scope_type, permission=self.perm) |
| 193 |
| 194 current_acl.entries.entry_list.append(entry) |
| 195 |
| 196 def Execute(self, uri, current_acl): |
| 197 """Executes the described change on an ACL. |
| 198 |
| 199 uri: The URI object to change. |
| 200 current_acl: An instance of boto.gs.acl.ACL to permute. |
| 201 """ |
| 202 self.logger.debug('Executing {0} on {1}' |
| 203 .format(self.raw_descriptor, uri)) |
| 204 |
| 205 if self.perm == 'WRITE' and uri.names_object(): |
| 206 self.logger.warn( |
| 207 'Skipping {0} on {1}, as WRITE does not apply to objects' |
| 208 .format(self.raw_descriptor, uri)) |
| 209 return 0 |
| 210 |
| 211 matching_entries = list(self._YieldMatchingEntries(current_acl)) |
| 212 change_count = 0 |
| 213 if matching_entries: |
| 214 for entry in matching_entries: |
| 215 if entry.permission != self.perm: |
| 216 entry.permission = self.perm |
| 217 change_count += 1 |
| 218 else: |
| 219 self._AddEntry(current_acl) |
| 220 change_count = 1 |
| 221 |
| 222 parsed_acl = minidom.parseString(current_acl.to_xml()) |
| 223 self.logger.debug('New Acl:\n{0}'.format(parsed_acl.toprettyxml())) |
| 224 return change_count |
| 225 |
| 226 |
| 227 class AclDel(AclChange): |
| 228 """Represents a logical change from an access control list.""" |
| 229 scope_regexes = { |
| 230 r'All(Users)?': 'AllUsers', |
| 231 r'AllAuth(enticatedUsers)?': 'AllAuthenticatedUsers', |
| 232 } |
| 233 |
| 234 def __init__(self, identifier, logger): |
| 235 self.raw_descriptor = '-d {0}'.format(identifier) |
| 236 self.logger = logger |
| 237 self.identifier = identifier |
| 238 for regex, scope in self.scope_regexes.items(): |
| 239 if re.match(regex, self.identifier, re.IGNORECASE): |
| 240 self.identifier = scope |
| 241 self.scope_type = 'Any' |
| 242 self.perm = 'NONE' |
| 243 |
| 244 def _YieldMatchingEntries(self, current_acl): |
| 245 for entry in current_acl.entries.entry_list: |
| 246 if self.identifier == entry.scope.id: |
| 247 yield entry |
| 248 elif self.identifier == entry.scope.email_address: |
| 249 yield entry |
| 250 elif self.identifier == entry.scope.domain: |
| 251 yield entry |
| 252 elif self.identifier == 'AllUsers' and entry.scope.type == 'AllUsers': |
| 253 yield entry |
| 254 elif (self.identifier == 'AllAuthenticatedUsers' |
| 255 and entry.scope.type == 'AllAuthenticatedUsers'): |
| 256 yield entry |
| 257 |
| 258 def Execute(self, uri, current_acl): |
| 259 self.logger.debug('Executing {0} on {1}' |
| 260 .format(self.raw_descriptor, uri)) |
| 261 matching_entries = list(self._YieldMatchingEntries(current_acl)) |
| 262 for entry in matching_entries: |
| 263 current_acl.entries.entry_list.remove(entry) |
| 264 parsed_acl = minidom.parseString(current_acl.to_xml()) |
| 265 self.logger.debug('New Acl:\n{0}'.format(parsed_acl.toprettyxml())) |
| 266 return len(matching_entries) |
| 267 |
| 268 |
| 269 _detailed_help_text = (""" |
| 270 <B>SYNOPSIS</B> |
| 271 gsutil chacl [-R] -u|-g|-d <grant>... uri... |
| 272 |
| 273 where each <grant> is one of the following forms: |
| 274 -u <id|email>:<perm> |
| 275 -g <id|email|domain|All|AllAuth>:<perm> |
| 276 -d <id|email|domain|All|AllAuth> |
| 277 |
| 278 <B>DESCRIPTION</B> |
| 279 The chacl command updates access control lists, similar in spirit to the Linux |
| 280 chmod command. You can specify multiple access grant additions and deletions |
| 281 in a single command run; all changes will be made atomically to each object in |
| 282 turn. For example, if the command requests deleting one grant and adding a |
| 283 different grant, the ACLs being updated will never be left in an intermediate |
| 284 state where one grant has been deleted but the second grant not yet added. |
| 285 Each change specifies a user or group grant to add or delete, and for grant |
| 286 additions, one of R, W, FC (for the permission to be granted). A more formal |
| 287 description is provided in a later section; below we provide examples. |
| 288 |
| 289 Note: If you want to set a simple "canned" ACL on each object (such as |
| 290 project-private or public), or if you prefer to edit the XML representation |
| 291 for ACLs, you can do that with the setacl command (see 'gsutil help setacl'). |
| 292 |
| 293 |
| 294 <B>EXAMPLES</B> |
| 295 |
| 296 Grant the user john.doe@example.com WRITE access to the bucket |
| 297 example-bucket: |
| 298 |
| 299 gsutil chacl -u john.doe@example.com:WRITE gs://example-bucket |
| 300 |
| 301 Grant the group admins@example.com FULL_CONTROL access to all jpg files in |
| 302 the top level of example-bucket: |
| 303 |
| 304 gsutil chacl -g admins@example.com:FC gs://example-bucket/*.jpg |
| 305 |
| 306 Grant the user with the specified canonical ID READ access to all objects in |
| 307 example-bucket that begin with folder/: |
| 308 |
| 309 gsutil chacl -R \\ |
| 310 -u 84fac329bceSAMPLE777d5d22b8SAMPLE77d85ac2SAMPLE2dfcf7c4adf34da46:R \\ |
| 311 gs://example-bucket/folder/ |
| 312 |
| 313 Grant all users from my-domain.org READ access to the bucket |
| 314 gcs.my-domain.org: |
| 315 |
| 316 gsutil chacl -g my-domain.org:R gs://gcs.my-domain.org |
| 317 |
| 318 Remove any current access by john.doe@example.com from the bucket |
| 319 example-bucket: |
| 320 |
| 321 gsutil chacl -d john.doe@example.com gs://example-bucket |
| 322 |
| 323 If you have a large number of objects to update, enabling multi-threading with |
| 324 the gsutil -m flag can significantly improve performance. The following |
| 325 command adds FULL_CONTROL for admin@example.org using multi-threading: |
| 326 |
| 327 gsutil -m chacl -R -u admin@example.org:FC gs://example-bucket |
| 328 |
| 329 Grant READ access to everyone from my-domain.org and to all authenticated |
| 330 users, and grant FULL_CONTROL to admin@mydomain.org, for the buckets |
| 331 my-bucket and my-other-bucket, with multi-threading enabled: |
| 332 |
| 333 gsutil -m chacl -R -g my-domain.org:R -g AllAuth:R \\ |
| 334 -u admin@mydomain.org:FC gs://my-bucket/ gs://my-other-bucket |
| 335 |
| 336 |
| 337 <B>SCOPES</B> |
| 338 There are four different scopes: Users, Groups, All Authenticated Users, and |
| 339 All Users. |
| 340 |
| 341 Users are added with -u and a plain ID or email address, as in |
| 342 "-u john-doe@gmail.com:r" |
| 343 |
| 344 Groups are like users, but specified with the -g flag, as in |
| 345 "-g power-users@example.com:fc". Groups may also be specified as a full |
| 346 domain, as in "-g my-company.com:r". |
| 347 |
| 348 AllAuthenticatedUsers and AllUsers are specified directly, as |
| 349 in "-g AllUsers:R" or "-g AllAuthenticatedUsers:FC". These are case |
| 350 insensitive, and may be shortened to "all" and "allauth", respectively. |
| 351 |
| 352 Removing permissions is specified with the -d flag and an ID, email |
| 353 address, domain, or one of AllUsers or AllAuthenticatedUsers. |
| 354 |
| 355 Many scopes can be specified on the same command line, allowing bundled |
| 356 changes to be executed in a single run. This will reduce the number of |
| 357 requests made to the server. |
| 358 |
| 359 |
| 360 <B>PERMISSIONS</B> |
| 361 You may specify the following permissions with either their shorthand or |
| 362 their full name: |
| 363 |
| 364 R: READ |
| 365 W: WRITE |
| 366 FC: FULL_CONTROL |
| 367 |
| 368 |
| 369 <B>OPTIONS</B> |
| 370 -R, -r Performs chacl request recursively, to all objects under the |
| 371 specified URI. |
| 372 |
| 373 -u Add or modify a user permission as specified in the SCOPES |
| 374 and PERMISSIONS sections. |
| 375 |
| 376 -g Add or modify a group permission as specified in the SCOPES |
| 377 and PERMISSIONS sections. |
| 378 |
| 379 -d Remove all permissions associated with the matching argument, as |
| 380 specified in the SCOPES and PERMISSIONS sections. |
| 381 """) |
| 382 |
| 383 |
| 384 class ChAclCommand(Command): |
| 385 """Implementation of gsutil chacl command.""" |
| 386 |
| 387 # Command specification (processed by parent class). |
| 388 command_spec = { |
| 389 # Name of command. |
| 390 COMMAND_NAME : 'chacl', |
| 391 # List of command name aliases. |
| 392 COMMAND_NAME_ALIASES : [], |
| 393 # Min number of args required by this command. |
| 394 MIN_ARGS : 1, |
| 395 # Max number of args required by this command, or NO_MAX. |
| 396 MAX_ARGS : NO_MAX, |
| 397 # Getopt-style string specifying acceptable sub args. |
| 398 SUPPORTED_SUB_ARGS : 'Rrfg:u:d:', |
| 399 # True if file URIs acceptable for this command. |
| 400 FILE_URIS_OK : False, |
| 401 # True if provider-only URIs acceptable for this command. |
| 402 PROVIDER_URIS_OK : False, |
| 403 # Index in args of first URI arg. |
| 404 URIS_START_ARG : 1, |
| 405 # True if must configure gsutil before running command. |
| 406 CONFIG_REQUIRED : True, |
| 407 } |
| 408 help_spec = { |
| 409 # Name of command or auxiliary help info for which this help applies. |
| 410 HELP_NAME : 'chacl', |
| 411 # List of help name aliases. |
| 412 HELP_NAME_ALIASES : ['chmod'], |
| 413 # Type of help: |
| 414 HELP_TYPE : HelpType.COMMAND_HELP, |
| 415 # One line summary of this help. |
| 416 HELP_ONE_LINE_SUMMARY : 'Add / remove entries on bucket and/or object ACLs', |
| 417 # The full help text. |
| 418 HELP_TEXT : _detailed_help_text, |
| 419 } |
| 420 |
| 421 # Command entry point. |
| 422 def RunCommand(self): |
| 423 """This is the point of entry for the chacl command.""" |
| 424 self.parse_versions = True |
| 425 self.changes = [] |
| 426 |
| 427 if self.sub_opts: |
| 428 for o, a in self.sub_opts: |
| 429 if o == '-g': |
| 430 self.changes.append(AclChange(a, scope_type=ChangeType.GROUP, |
| 431 logger=self.THREADED_LOGGER)) |
| 432 if o == '-u': |
| 433 self.changes.append(AclChange(a, scope_type=ChangeType.USER, |
| 434 logger=self.THREADED_LOGGER)) |
| 435 if o == '-d': |
| 436 self.changes.append(AclDel(a, logger=self.THREADED_LOGGER)) |
| 437 |
| 438 if not self.changes: |
| 439 raise CommandException( |
| 440 'Please specify at least one access change ' |
| 441 'with the -g, -u, or -d flags') |
| 442 |
| 443 storage_uri = self.UrisAreForSingleProvider(self.args) |
| 444 if not (storage_uri and storage_uri.get_provider().name == 'google'): |
| 445 raise CommandException('The "{0}" command can only be used with gs:// URIs
' |
| 446 .format(self.command_name)) |
| 447 |
| 448 bulk_uris = set() |
| 449 for uri_arg in self.args: |
| 450 for result in self.WildcardIterator(uri_arg): |
| 451 uri = result.uri |
| 452 if uri.names_bucket(): |
| 453 if self.recursion_requested: |
| 454 bulk_uris.add(uri.clone_replace_name('*').uri) |
| 455 else: |
| 456 # If applying to a bucket directly, the threading machinery will |
| 457 # break, so we have to apply now, in the main thread. |
| 458 self.ApplyAclChanges(uri) |
| 459 else: |
| 460 bulk_uris.add(uri_arg) |
| 461 |
| 462 try: |
| 463 name_expansion_iterator = name_expansion.NameExpansionIterator( |
| 464 self.command_name, self.proj_id_handler, self.headers, self.debug, |
| 465 self.bucket_storage_uri_class, bulk_uris, self.recursion_requested) |
| 466 except CommandException as e: |
| 467 # NameExpansionIterator will complain if there are no URIs, but we don't |
| 468 # want to throw an error if we handled bucket URIs. |
| 469 if e.reason == 'No URIs matched': |
| 470 return 0 |
| 471 else: |
| 472 raise e |
| 473 |
| 474 self.everything_set_okay = True |
| 475 self.Apply(self.ApplyAclChanges, |
| 476 name_expansion_iterator, |
| 477 self._ApplyExceptionHandler) |
| 478 if not self.everything_set_okay: |
| 479 raise CommandException('ACLs for some objects could not be set.') |
| 480 |
| 481 return 0 |
| 482 |
| 483 def _ApplyExceptionHandler(self, exception): |
| 484 self.THREADED_LOGGER.error('Encountered a problem: {0}'.format(exception)) |
| 485 self.everything_set_okay = False |
| 486 |
| 487 @Retry(GSResponseError, tries=3, delay=1, backoff=2) |
| 488 def ApplyAclChanges(self, uri_or_expansion_result): |
| 489 """Applies the changes in self.changes to the provided URI.""" |
| 490 if isinstance(uri_or_expansion_result, name_expansion.NameExpansionResult): |
| 491 uri = self.suri_builder.StorageUri( |
| 492 uri_or_expansion_result.expanded_uri_str) |
| 493 else: |
| 494 uri = uri_or_expansion_result |
| 495 |
| 496 try: |
| 497 current_acl = uri.get_acl() |
| 498 except GSResponseError as e: |
| 499 self.THREADED_LOGGER.warning('Failed to set acl for {0}: {1}' |
| 500 .format(uri, e.reason)) |
| 501 return |
| 502 |
| 503 modification_count = 0 |
| 504 for change in self.changes: |
| 505 modification_count += change.Execute(uri, current_acl) |
| 506 if modification_count == 0: |
| 507 self.THREADED_LOGGER.info('No changes to {0}'.format(uri)) |
| 508 return |
| 509 |
| 510 # TODO: Remove the concept of forcing when boto provides access to |
| 511 # bucket generation and meta_generation. |
| 512 headers = dict(self.headers) |
| 513 force = uri.names_bucket() |
| 514 if not force: |
| 515 key = uri.get_key() |
| 516 headers['x-goog-if-generation-match'] = key.generation |
| 517 headers['x-goog-if-metageneration-match'] = key.meta_generation |
| 518 |
| 519 # If this fails because of a precondition, it will raise a |
| 520 # GSResponseError for @Retry to handle. |
| 521 uri.set_acl(current_acl, uri.object_name, False, headers) |
| 522 self.THREADED_LOGGER.info('Updated ACL on {0}'.format(uri)) |
| 523 |
OLD | NEW |