| # Copyright 2013 Google Inc. All Rights Reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| """ |
| This module provides the chacl command to gsutil. |
| |
| This command allows users to easily specify changes to access control lists. |
| """ |
| |
| import random |
| import re |
| import time |
| from xml.dom import minidom |
| from boto.exception import GSResponseError |
| from boto.gs import acl |
| from gslib import name_expansion |
| from gslib.command import Command |
| from gslib.command import COMMAND_NAME |
| from gslib.command import COMMAND_NAME_ALIASES |
| from gslib.command import CONFIG_REQUIRED |
| from gslib.command import FILE_URIS_OK |
| from gslib.command import MAX_ARGS |
| from gslib.command import MIN_ARGS |
| from gslib.command import PROVIDER_URIS_OK |
| from gslib.command import SUPPORTED_SUB_ARGS |
| from gslib.command import URIS_START_ARG |
| from gslib.exception import CommandException |
| from gslib.help_provider import HELP_NAME |
| from gslib.help_provider import HELP_NAME_ALIASES |
| from gslib.help_provider import HELP_ONE_LINE_SUMMARY |
| from gslib.help_provider import HELP_TEXT |
| from gslib.help_provider import HELP_TYPE |
| from gslib.help_provider import HelpType |
| from gslib.util import NO_MAX |
| from gslib.util import Retry |
| |
| |
| class ChangeType(object): |
| USER = 'User' |
| GROUP = 'Group' |
| |
| |
| class AclChange(object): |
| """Represents a logical change to an access control list.""" |
| public_scopes = ['AllAuthenticatedUsers', 'AllUsers'] |
| id_scopes = ['UserById', 'GroupById'] |
| email_scopes = ['UserByEmail', 'GroupByEmail'] |
| domain_scopes = ['GroupByDomain'] |
| scope_types = public_scopes + id_scopes + email_scopes + domain_scopes |
| |
| permission_shorthand_mapping = { |
| 'R': 'READ', |
| 'W': 'WRITE', |
| 'FC': 'FULL_CONTROL', |
| } |
| |
| def __init__(self, acl_change_descriptor, scope_type, logger): |
| """Creates an AclChange object. |
| |
| acl_change_descriptor: An acl change as described in chacl help. |
| scope_type: Either ChangeType.USER or ChangeType.GROUP, specifying the |
| extent of the scope. |
| logger: An instance of ThreadedLogger. |
| """ |
| self.logger = logger |
| self.identifier = '' |
| |
| self.raw_descriptor = acl_change_descriptor |
| self._Parse(acl_change_descriptor, scope_type) |
| self._Validate() |
| |
| def __str__(self): |
| return 'AclChange<{0}|{1}|{2}>'.format(self.scope_type, self.perm, |
| self.identifier) |
| |
| def _Parse(self, change_descriptor, scope_type): |
| """Parses an ACL Change descriptor.""" |
| |
| def _ClassifyScopeIdentifier(text): |
| re_map = { |
| 'AllAuthenticatedUsers': r'^(AllAuthenticatedUsers|AllAuth)$', |
| 'AllUsers': '^(AllUsers|All)$', |
| 'Email': r'^.+@.+\..+$', |
| 'Id': r'^[0-9A-Fa-f]{64}$', |
| 'Domain': r'^[^@]+\..+$', |
| } |
| for type_string, regex in re_map.items(): |
| if re.match(regex, text, re.IGNORECASE): |
| return type_string |
| |
| if change_descriptor.count(':') != 1: |
| raise CommandException('{0} is an invalid change description.' |
| .format(change_descriptor)) |
| |
| scope_string, perm_token = change_descriptor.split(':') |
| |
| perm_token = perm_token.upper() |
| if perm_token in self.permission_shorthand_mapping: |
| self.perm = self.permission_shorthand_mapping[perm_token] |
| else: |
| self.perm = perm_token |
| |
| scope_class = _ClassifyScopeIdentifier(scope_string) |
| if scope_class == 'Domain': |
| # This may produce an invalid UserByDomain scope, |
| # which is good because then validate can complain. |
| self.scope_type = '{0}ByDomain'.format(scope_type) |
| self.identifier = scope_string |
| elif scope_class in ['Email', 'Id']: |
| self.scope_type = '{0}By{1}'.format(scope_type, scope_class) |
| self.identifier = scope_string |
| elif scope_class == 'AllAuthenticatedUsers': |
| self.scope_type = 'AllAuthenticatedUsers' |
| elif scope_class == 'AllUsers': |
| self.scope_type = 'AllUsers' |
| else: |
| # This is just a fallback, so we set it to something |
| # and the validate step has something to go on. |
| self.scope_type = scope_string |
| |
| def _Validate(self): |
| """Validates a parsed AclChange object.""" |
| |
| def _ThrowError(msg): |
| raise CommandException('{0} is not a valid ACL change\n{1}' |
| .format(self.raw_descriptor, msg)) |
| |
| if self.scope_type not in self.scope_types: |
| _ThrowError('{0} is not a valid scope type'.format(self.scope_type)) |
| |
| if self.scope_type in self.public_scopes and self.identifier: |
| _ThrowError('{0} requires no arguments'.format(self.scope_type)) |
| |
| if self.scope_type in self.id_scopes and not self.identifier: |
| _ThrowError('{0} requires an id'.format(self.scope_type)) |
| |
| if self.scope_type in self.email_scopes and not self.identifier: |
| _ThrowError('{0} requires an email address'.format(self.scope_type)) |
| |
| if self.scope_type in self.domain_scopes and not self.identifier: |
| _ThrowError('{0} requires domain'.format(self.scope_type)) |
| |
| if self.perm not in self.permission_shorthand_mapping.values(): |
| perms = ', '.join(self.permission_shorthand_mapping.values()) |
| _ThrowError('Allowed permissions are {0}'.format(perms)) |
| |
| def _YieldMatchingEntries(self, current_acl): |
| """Generator that yields entries that match the change descriptor. |
| |
| current_acl: An instance of bogo.gs.acl.ACL which will be searched |
| for matching entries. |
| """ |
| for entry in current_acl.entries.entry_list: |
| if entry.scope.type == self.scope_type: |
| if self.scope_type in ['UserById', 'GroupById']: |
| if self.identifier == entry.scope.id: |
| yield entry |
| elif self.scope_type in ['UserByEmail', 'GroupByEmail']: |
| if self.identifier == entry.scope.email_address: |
| yield entry |
| elif self.scope_type == 'GroupByDomain': |
| if self.identifier == entry.scope.domain: |
| yield entry |
| elif self.scope_type in ['AllUsers', 'AllAuthenticatedUsers']: |
| yield entry |
| else: |
| raise CommandException('Found an unrecognized ACL ' |
| 'entry type, aborting.') |
| |
| def _AddEntry(self, current_acl): |
| """Adds an entry to an ACL.""" |
| if self.scope_type in ['UserById', 'UserById', 'GroupById']: |
| entry = acl.Entry(type=self.scope_type, permission=self.perm, |
| id=self.identifier) |
| elif self.scope_type in ['UserByEmail', 'GroupByEmail']: |
| entry = acl.Entry(type=self.scope_type, permission=self.perm, |
| email_address=self.identifier) |
| elif self.scope_type == 'GroupByDomain': |
| entry = acl.Entry(type=self.scope_type, permission=self.perm, |
| domain=self.identifier) |
| else: |
| entry = acl.Entry(type=self.scope_type, permission=self.perm) |
| |
| current_acl.entries.entry_list.append(entry) |
| |
| def Execute(self, uri, current_acl): |
| """Executes the described change on an ACL. |
| |
| uri: The URI object to change. |
| current_acl: An instance of boto.gs.acl.ACL to permute. |
| """ |
| self.logger.debug('Executing {0} on {1}' |
| .format(self.raw_descriptor, uri)) |
| |
| if self.perm == 'WRITE' and uri.names_object(): |
| self.logger.warn( |
| 'Skipping {0} on {1}, as WRITE does not apply to objects' |
| .format(self.raw_descriptor, uri)) |
| return 0 |
| |
| matching_entries = list(self._YieldMatchingEntries(current_acl)) |
| change_count = 0 |
| if matching_entries: |
| for entry in matching_entries: |
| if entry.permission != self.perm: |
| entry.permission = self.perm |
| change_count += 1 |
| else: |
| self._AddEntry(current_acl) |
| change_count = 1 |
| |
| parsed_acl = minidom.parseString(current_acl.to_xml()) |
| self.logger.debug('New Acl:\n{0}'.format(parsed_acl.toprettyxml())) |
| return change_count |
| |
| |
| class AclDel(AclChange): |
| """Represents a logical change from an access control list.""" |
| scope_regexes = { |
| r'All(Users)?': 'AllUsers', |
| r'AllAuth(enticatedUsers)?': 'AllAuthenticatedUsers', |
| } |
| |
| def __init__(self, identifier, logger): |
| self.raw_descriptor = '-d {0}'.format(identifier) |
| self.logger = logger |
| self.identifier = identifier |
| for regex, scope in self.scope_regexes.items(): |
| if re.match(regex, self.identifier, re.IGNORECASE): |
| self.identifier = scope |
| self.scope_type = 'Any' |
| self.perm = 'NONE' |
| |
| def _YieldMatchingEntries(self, current_acl): |
| for entry in current_acl.entries.entry_list: |
| if self.identifier == entry.scope.id: |
| yield entry |
| elif self.identifier == entry.scope.email_address: |
| yield entry |
| elif self.identifier == entry.scope.domain: |
| yield entry |
| elif self.identifier == 'AllUsers' and entry.scope.type == 'AllUsers': |
| yield entry |
| elif (self.identifier == 'AllAuthenticatedUsers' |
| and entry.scope.type == 'AllAuthenticatedUsers'): |
| yield entry |
| |
| def Execute(self, uri, current_acl): |
| self.logger.debug('Executing {0} on {1}' |
| .format(self.raw_descriptor, uri)) |
| matching_entries = list(self._YieldMatchingEntries(current_acl)) |
| for entry in matching_entries: |
| current_acl.entries.entry_list.remove(entry) |
| parsed_acl = minidom.parseString(current_acl.to_xml()) |
| self.logger.debug('New Acl:\n{0}'.format(parsed_acl.toprettyxml())) |
| return len(matching_entries) |
| |
| |
| _detailed_help_text = (""" |
| <B>SYNOPSIS</B> |
| gsutil chacl [-R] -u|-g|-d <grant>... uri... |
| |
| where each <grant> is one of the following forms: |
| -u <id|email>:<perm> |
| -g <id|email|domain|All|AllAuth>:<perm> |
| -d <id|email|domain|All|AllAuth> |
| |
| <B>DESCRIPTION</B> |
| The chacl command updates access control lists, similar in spirit to the Linux |
| chmod command. You can specify multiple access grant additions and deletions |
| in a single command run; all changes will be made atomically to each object in |
| turn. For example, if the command requests deleting one grant and adding a |
| different grant, the ACLs being updated will never be left in an intermediate |
| state where one grant has been deleted but the second grant not yet added. |
| Each change specifies a user or group grant to add or delete, and for grant |
| additions, one of R, W, FC (for the permission to be granted). A more formal |
| description is provided in a later section; below we provide examples. |
| |
| Note: If you want to set a simple "canned" ACL on each object (such as |
| project-private or public), or if you prefer to edit the XML representation |
| for ACLs, you can do that with the setacl command (see 'gsutil help setacl'). |
| |
| |
| <B>EXAMPLES</B> |
| |
| Grant the user john.doe@example.com WRITE access to the bucket |
| example-bucket: |
| |
| gsutil chacl -u john.doe@example.com:WRITE gs://example-bucket |
| |
| Grant the group admins@example.com FULL_CONTROL access to all jpg files in |
| the top level of example-bucket: |
| |
| gsutil chacl -g admins@example.com:FC gs://example-bucket/*.jpg |
| |
| Grant the user with the specified canonical ID READ access to all objects in |
| example-bucket that begin with folder/: |
| |
| gsutil chacl -R \\ |
| -u 84fac329bceSAMPLE777d5d22b8SAMPLE77d85ac2SAMPLE2dfcf7c4adf34da46:R \\ |
| gs://example-bucket/folder/ |
| |
| Grant all users from my-domain.org READ access to the bucket |
| gcs.my-domain.org: |
| |
| gsutil chacl -g my-domain.org:R gs://gcs.my-domain.org |
| |
| Remove any current access by john.doe@example.com from the bucket |
| example-bucket: |
| |
| gsutil chacl -d john.doe@example.com gs://example-bucket |
| |
| If you have a large number of objects to update, enabling multi-threading with |
| the gsutil -m flag can significantly improve performance. The following |
| command adds FULL_CONTROL for admin@example.org using multi-threading: |
| |
| gsutil -m chacl -R -u admin@example.org:FC gs://example-bucket |
| |
| Grant READ access to everyone from my-domain.org and to all authenticated |
| users, and grant FULL_CONTROL to admin@mydomain.org, for the buckets |
| my-bucket and my-other-bucket, with multi-threading enabled: |
| |
| gsutil -m chacl -R -g my-domain.org:R -g AllAuth:R \\ |
| -u admin@mydomain.org:FC gs://my-bucket/ gs://my-other-bucket |
| |
| |
| <B>SCOPES</B> |
| There are four different scopes: Users, Groups, All Authenticated Users, and |
| All Users. |
| |
| Users are added with -u and a plain ID or email address, as in |
| "-u john-doe@gmail.com:r" |
| |
| Groups are like users, but specified with the -g flag, as in |
| "-g power-users@example.com:fc". Groups may also be specified as a full |
| domain, as in "-g my-company.com:r". |
| |
| AllAuthenticatedUsers and AllUsers are specified directly, as |
| in "-g AllUsers:R" or "-g AllAuthenticatedUsers:FC". These are case |
| insensitive, and may be shortened to "all" and "allauth", respectively. |
| |
| Removing permissions is specified with the -d flag and an ID, email |
| address, domain, or one of AllUsers or AllAuthenticatedUsers. |
| |
| Many scopes can be specified on the same command line, allowing bundled |
| changes to be executed in a single run. This will reduce the number of |
| requests made to the server. |
| |
| |
| <B>PERMISSIONS</B> |
| You may specify the following permissions with either their shorthand or |
| their full name: |
| |
| R: READ |
| W: WRITE |
| FC: FULL_CONTROL |
| |
| |
| <B>OPTIONS</B> |
| -R, -r Performs chacl request recursively, to all objects under the |
| specified URI. |
| |
| -u Add or modify a user permission as specified in the SCOPES |
| and PERMISSIONS sections. |
| |
| -g Add or modify a group permission as specified in the SCOPES |
| and PERMISSIONS sections. |
| |
| -d Remove all permissions associated with the matching argument, as |
| specified in the SCOPES and PERMISSIONS sections. |
| """) |
| |
| |
| class ChAclCommand(Command): |
| """Implementation of gsutil chacl command.""" |
| |
| # Command specification (processed by parent class). |
| command_spec = { |
| # Name of command. |
| COMMAND_NAME : 'chacl', |
| # List of command name aliases. |
| COMMAND_NAME_ALIASES : [], |
| # Min number of args required by this command. |
| MIN_ARGS : 1, |
| # Max number of args required by this command, or NO_MAX. |
| MAX_ARGS : NO_MAX, |
| # Getopt-style string specifying acceptable sub args. |
| SUPPORTED_SUB_ARGS : 'Rrfg:u:d:', |
| # True if file URIs acceptable for this command. |
| FILE_URIS_OK : False, |
| # True if provider-only URIs acceptable for this command. |
| PROVIDER_URIS_OK : False, |
| # Index in args of first URI arg. |
| URIS_START_ARG : 1, |
| # True if must configure gsutil before running command. |
| CONFIG_REQUIRED : True, |
| } |
| help_spec = { |
| # Name of command or auxiliary help info for which this help applies. |
| HELP_NAME : 'chacl', |
| # List of help name aliases. |
| HELP_NAME_ALIASES : ['chmod'], |
| # Type of help: |
| HELP_TYPE : HelpType.COMMAND_HELP, |
| # One line summary of this help. |
| HELP_ONE_LINE_SUMMARY : 'Add / remove entries on bucket and/or object ACLs', |
| # The full help text. |
| HELP_TEXT : _detailed_help_text, |
| } |
| |
| # Command entry point. |
| def RunCommand(self): |
| """This is the point of entry for the chacl command.""" |
| self.parse_versions = True |
| self.changes = [] |
| |
| if self.sub_opts: |
| for o, a in self.sub_opts: |
| if o == '-g': |
| self.changes.append(AclChange(a, scope_type=ChangeType.GROUP, |
| logger=self.THREADED_LOGGER)) |
| if o == '-u': |
| self.changes.append(AclChange(a, scope_type=ChangeType.USER, |
| logger=self.THREADED_LOGGER)) |
| if o == '-d': |
| self.changes.append(AclDel(a, logger=self.THREADED_LOGGER)) |
| |
| if not self.changes: |
| raise CommandException( |
| 'Please specify at least one access change ' |
| 'with the -g, -u, or -d flags') |
| |
| storage_uri = self.UrisAreForSingleProvider(self.args) |
| if not (storage_uri and storage_uri.get_provider().name == 'google'): |
| raise CommandException('The "{0}" command can only be used with gs:// URIs' |
| .format(self.command_name)) |
| |
| bulk_uris = set() |
| for uri_arg in self.args: |
| for result in self.WildcardIterator(uri_arg): |
| uri = result.uri |
| if uri.names_bucket(): |
| if self.recursion_requested: |
| bulk_uris.add(uri.clone_replace_name('*').uri) |
| else: |
| # If applying to a bucket directly, the threading machinery will |
| # break, so we have to apply now, in the main thread. |
| self.ApplyAclChanges(uri) |
| else: |
| bulk_uris.add(uri_arg) |
| |
| try: |
| name_expansion_iterator = name_expansion.NameExpansionIterator( |
| self.command_name, self.proj_id_handler, self.headers, self.debug, |
| self.bucket_storage_uri_class, bulk_uris, self.recursion_requested) |
| except CommandException as e: |
| # NameExpansionIterator will complain if there are no URIs, but we don't |
| # want to throw an error if we handled bucket URIs. |
| if e.reason == 'No URIs matched': |
| return 0 |
| else: |
| raise e |
| |
| self.everything_set_okay = True |
| self.Apply(self.ApplyAclChanges, |
| name_expansion_iterator, |
| self._ApplyExceptionHandler) |
| if not self.everything_set_okay: |
| raise CommandException('ACLs for some objects could not be set.') |
| |
| return 0 |
| |
| def _ApplyExceptionHandler(self, exception): |
| self.THREADED_LOGGER.error('Encountered a problem: {0}'.format(exception)) |
| self.everything_set_okay = False |
| |
| @Retry(GSResponseError, tries=3, delay=1, backoff=2) |
| def ApplyAclChanges(self, uri_or_expansion_result): |
| """Applies the changes in self.changes to the provided URI.""" |
| if isinstance(uri_or_expansion_result, name_expansion.NameExpansionResult): |
| uri = self.suri_builder.StorageUri( |
| uri_or_expansion_result.expanded_uri_str) |
| else: |
| uri = uri_or_expansion_result |
| |
| try: |
| current_acl = uri.get_acl() |
| except GSResponseError as e: |
| self.THREADED_LOGGER.warning('Failed to set acl for {0}: {1}' |
| .format(uri, e.reason)) |
| return |
| |
| modification_count = 0 |
| for change in self.changes: |
| modification_count += change.Execute(uri, current_acl) |
| if modification_count == 0: |
| self.THREADED_LOGGER.info('No changes to {0}'.format(uri)) |
| return |
| |
| # TODO: Remove the concept of forcing when boto provides access to |
| # bucket generation and meta_generation. |
| headers = dict(self.headers) |
| force = uri.names_bucket() |
| if not force: |
| key = uri.get_key() |
| headers['x-goog-if-generation-match'] = key.generation |
| headers['x-goog-if-metageneration-match'] = key.meta_generation |
| |
| # If this fails because of a precondition, it will raise a |
| # GSResponseError for @Retry to handle. |
| uri.set_acl(current_acl, uri.object_name, False, headers) |
| self.THREADED_LOGGER.info('Updated ACL on {0}'.format(uri)) |
| |