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 |