Package trac :: Module perm

Source Code for Module trac.perm

  1  # -*- coding: utf-8 -*- 
  2  # 
  3  # Copyright (C) 2003-2023 Edgewall Software 
  4  # Copyright (C) 2003-2004 Jonas Borgström <[email protected]> 
  5  # Copyright (C) 2005 Christopher Lenz <[email protected]> 
  6  # All rights reserved. 
  7  # 
  8  # This software is licensed as described in the file COPYING, which 
  9  # you should have received as part of this distribution. The terms 
 10  # are also available at https://trac.edgewall.org/wiki/TracLicense. 
 11  # 
 12  # This software consists of voluntary contributions made by many 
 13  # individuals. For the exact contribution history, see the revision 
 14  # history and logs, available at https://trac.edgewall.org/log/. 
 15  # 
 16  # Author: Jonas Borgström <[email protected]> 
 17  #         Christopher Lenz <[email protected]> 
 18   
 19  from __future__ import print_function 
 20   
 21  import csv 
 22  import os 
 23  from itertools import groupby 
 24   
 25  from trac.admin import AdminCommandError, IAdminCommandProvider, get_dir_list 
 26  from trac.cache import cached 
 27  from trac.config import ExtensionOption, OrderedExtensionsOption 
 28  from trac.core import * 
 29  from trac.resource import Resource, get_resource_name 
 30  from trac.util import file_or_std, lazy 
 31  from trac.util.datefmt import time_now 
 32  from trac.util.text import path_to_unicode, print_table, printout, \ 
 33                             stream_encoding, to_unicode, wrap 
 34  from trac.util.translation import _, N_ 
 35   
 36  __all__ = ['IPermissionRequestor', 'IPermissionStore', 'IPermissionPolicy', 
 37             'IPermissionGroupProvider', 'PermissionError', 'PermissionSystem'] 
38 39 40 -class PermissionError(TracBaseError):
41 """Insufficient permissions to perform the operation.""" 42 43 title = N_("Forbidden") 44
45 - def __init__(self, action=None, resource=None, env=None, msg=None):
46 self.action = action 47 self.resource = resource 48 self.env = env 49 if self.action: 50 if self.resource and self.resource.id: 51 msg = _("%(perm)s privileges are required to perform " 52 "this operation on %(resource)s. You don't have the " 53 "required permissions.", 54 perm=self.action, 55 resource=get_resource_name(self.env, self.resource)) 56 else: 57 msg = _("%(perm)s privileges are required to perform this " 58 "operation. You don't have the required " 59 "permissions.", perm=self.action) 60 elif msg is None: 61 msg = _("Insufficient privileges to perform this operation.") 62 super(PermissionError, self).__init__(msg)
63 64 @property
65 - def message(self):
66 return self.args[0]
67
68 69 -class PermissionExistsError(TracError):
70 """Thrown when a unique key constraint is violated."""
71
72 73 -class IPermissionRequestor(Interface):
74 """Extension point interface for components that define actions.""" 75
77 """Return a list of actions defined by this component. 78 79 The items in the list may either be simple strings, or 80 `(string, sequence)` tuples. The latter are considered to be "meta 81 permissions" that group several simple actions under one name for 82 convenience, adding to it if another component already defined that 83 name. 84 """
85
86 87 -class IPermissionStore(Interface):
88 """Extension point interface for components that provide storage and 89 management of permissions. 90 """ 91
92 - def get_user_permissions(username):
93 """Return a list of permissions for the specified user. 94 """
95
96 - def get_users_with_permissions(permissions):
97 """Return a list of users that have any of the specified 98 permissions. 99 """
100
102 """Return all permissions for all users. 103 104 The permissions are returned as a list of (subject, action) 105 formatted tuples. 106 """
107
108 - def grant_permission(username, action):
109 """Grant a user permission to perform an action."""
110
111 - def revoke_permission(username, action):
112 """Revokes the permission of the given user to perform an action."""
113
114 115 -class IPermissionGroupProvider(Interface):
116 """Extension point interface for components that provide information 117 about user groups. 118 """ 119
120 - def get_permission_groups(username):
121 """Return a list of names of the groups that the user with the 122 specified name is a member of. 123 """
124
125 126 -class IPermissionPolicy(Interface):
127 """A security policy provider used for fine grained permission checks.""" 128
129 - def check_permission(action, username, resource, perm):
130 """Check that the action can be performed by username on the resource 131 132 :param action: the name of the permission 133 :param username: the username string or 'anonymous' if there's no 134 authenticated user 135 :param resource: the resource on which the check applies. 136 Will be `None`, if the check is a global one and 137 not made on a resource in particular 138 :param perm: the permission cache for that username and resource, 139 which can be used for doing secondary checks on other 140 permissions. Care must be taken to avoid recursion. 141 142 :return: `True` if action is allowed, `False` if action is denied, 143 or `None` if indifferent. If `None` is returned, the next 144 policy in the chain will be consulted. 145 146 Note that when checking a permission on a realm resource (i.e. when 147 `.id` is `None`), this usually corresponds to some preliminary check 148 done before making a fine-grained check on some resource. 149 Therefore the `IPermissionPolicy` should be conservative and return: 150 151 * `True` if the action *can* be allowed for some resources in 152 that realm. Later, for specific resource, the policy will be able 153 to return `True` (allow), `False` (deny) or `None` (don't decide). 154 * `None` if the action *can not* be performed for *some* resources. 155 This corresponds to situation where the policy is only interested 156 in returning `False` or `None` on specific resources. 157 * `False` if the action *can not* be performed for *any* resource 158 in that realm (that's a very strong decision as that will usually 159 prevent any fine-grained check to even happen). 160 161 Note that performing permission checks on realm resources may seem 162 redundant for now as the action name itself contains the realm, but 163 this will probably change in the future (e.g. `'VIEW' in ...`). 164 """
165
166 167 -class DefaultPermissionStore(Component):
168 """Default implementation of permission storage and group management. 169 170 This component uses the `permission` table in the database to store both 171 permissions and groups. 172 """ 173 implements(IPermissionGroupProvider, IPermissionStore) 174 175 group_providers = ExtensionPoint(IPermissionGroupProvider) 176 177 # IPermissionGroupProvider methods 178
179 - def get_permission_groups(self, username):
180 """Return a list of names of the groups that the user with the 181 specified name is a member of. 182 """ 183 return sorted(self._get_actions_and_groups({username})[1])
184 185 # IPermissionStore methods 186
187 - def get_user_permissions(self, username):
188 """Retrieve a list of permissions for the given user. 189 190 The permissions are stored in the database as (username, action) 191 records. There's simple support for groups by using lowercase 192 names for the action column: such a record represents a group and 193 not an actual permission, and declares that the user is part of 194 that group. 195 """ 196 subjects = {username} 197 for provider in self.group_providers: 198 if provider is not self: 199 subjects.update(provider.get_permission_groups(username) or []) 200 return sorted(self._get_actions_and_groups(subjects)[0])
201
202 - def get_users_with_permissions(self, permissions):
203 """Retrieve a list of users that have any of the specified 204 permissions 205 206 Users are returned as a list of usernames. 207 """ 208 # get_user_permissions() takes care of the magic 'authenticated' 209 # group. The optimized loop we had before didn't. This is very 210 # inefficient, but it works. 211 result = set() 212 users = {u[0] for u in self.env.get_known_users()} 213 for user in users: 214 user_perms = self.get_user_permissions(user) 215 for group in permissions: 216 if group in user_perms: 217 result.add(user) 218 return sorted(result)
219
220 - def get_all_permissions(self):
221 """Return all permissions for all users. 222 223 The permissions are returned as a list of (subject, action) 224 formatted tuples. 225 """ 226 return self._all_permissions
227
228 - def grant_permission(self, username, action):
229 """Grants a user the permission to perform the specified action.""" 230 self.env.db_transaction("INSERT INTO permission VALUES (%s, %s)", 231 (username, action)) 232 self.log.info("Granted permission for %s to %s", action, username) 233 234 # Invalidate cached property 235 del self._all_permissions
236
237 - def revoke_permission(self, username, action):
238 """Revokes a users' permission to perform the specified action.""" 239 self.env.db_transaction( 240 "DELETE FROM permission WHERE username=%s AND action=%s", 241 (username, action)) 242 self.log.info("Revoked permission for %s to %s", action, username) 243 244 # Invalidate cached property 245 del self._all_permissions
246 247 @cached
248 - def _all_permissions(self):
249 return sorted(self.env.db_query(""" 250 SELECT username, action FROM permission 251 """))
252
253 - def _get_actions_and_groups(self, subjects):
254 """Get actions and groups for `subjects`, an iterable of username 255 and groups that username is a member of. 256 """ 257 actions = set() 258 groups = set() 259 perms = self._all_permissions 260 while True: 261 num_users = len(subjects) 262 num_actions = len(actions) 263 for user, action in perms: 264 if user in subjects: 265 if action.isupper(): 266 actions.add(action) 267 if not action.isupper(): # permission group 268 subjects.add(action) 269 groups.add(action) 270 if num_users == len(subjects) and num_actions == len(actions): 271 break 272 return actions, groups
273
274 275 -class DefaultPermissionGroupProvider(Component):
276 """Permission group provider providing the basic builtin permission 277 groups 'anonymous' and 'authenticated'. 278 """ 279 280 required = True 281 282 implements(IPermissionGroupProvider) 283
284 - def get_permission_groups(self, username):
285 groups = ['anonymous'] 286 if username and username != 'anonymous': 287 groups.append('authenticated') 288 return groups
289
290 291 -class DefaultPermissionPolicy(Component):
292 """Default permission policy using the IPermissionStore system.""" 293 294 implements(IPermissionPolicy) 295 296 # Number of seconds a cached user permission set is valid for. 297 CACHE_EXPIRY = 5 298 # How frequently to clear the entire permission cache 299 CACHE_REAP_TIME = 60 300
301 - def __init__(self):
302 self.permission_cache = {} 303 self.last_reap = time_now()
304 305 # IPermissionPolicy methods 306
307 - def check_permission(self, action, username, resource, perm):
308 now = time_now() 309 310 if now - self.last_reap > self.CACHE_REAP_TIME: 311 self.permission_cache = {} 312 self.last_reap = time_now() 313 314 timestamp, permissions = \ 315 self.permission_cache.get(username, (0, None)) 316 317 # Cache hit? 318 if now - timestamp > self.CACHE_EXPIRY: 319 # No, pull permissions from database. 320 permissions = PermissionSystem(self.env). \ 321 get_user_permissions(username) 322 self.permission_cache[username] = (now, permissions) 323 324 return action in permissions or None
325
326 327 -class PermissionSystem(Component):
328 """Permission management sub-system.""" 329 330 required = True 331 332 implements(IPermissionRequestor) 333 334 requestors = ExtensionPoint(IPermissionRequestor) 335 group_providers = ExtensionPoint(IPermissionGroupProvider) 336 337 store = ExtensionOption('trac', 'permission_store', IPermissionStore, 338 'DefaultPermissionStore', 339 """Name of the component implementing `IPermissionStore`, which is 340 used for managing user and group permissions.""") 341 342 policies = OrderedExtensionsOption('trac', 'permission_policies', 343 IPermissionPolicy, 344 'DefaultWikiPolicy, DefaultTicketPolicy, DefaultPermissionPolicy, ' 345 'LegacyAttachmentPolicy', 346 False, 347 """List of components implementing `IPermissionPolicy`, in the order 348 in which they will be applied. These components manage fine-grained 349 access control to Trac resources.""") 350 351 # Number of seconds a cached user permission set is valid for. 352 CACHE_EXPIRY = 5 353 # How frequently to clear the entire permission cache 354 CACHE_REAP_TIME = 60 355
356 - def __init__(self):
357 self.permission_cache = {} 358 self.last_reap = time_now()
359 360 # Public API 361
362 - def grant_permission(self, username, action):
363 """Grant the user with the given name permission to perform to 364 specified action. 365 366 :raises PermissionExistsError: if user already has the permission 367 or is a member of the group. 368 369 :since 1.3.1: raises PermissionExistsError rather than IntegrityError 370 """ 371 if action.isupper() and action not in self.get_actions(): 372 raise TracError(_('%(name)s is not a valid action.', name=action)) 373 elif not action.isupper() and action.upper() in self.get_actions(): 374 raise TracError(_("Permission %(name)s differs from a defined " 375 "action by casing only, which is not allowed.", 376 name=action)) 377 378 try: 379 self.store.grant_permission(username, action) 380 except self.env.db_exc.IntegrityError: 381 if action in self.get_actions(): 382 raise PermissionExistsError( 383 _("The user %(user)s already has permission %(action)s.", 384 user=username, action=action)) 385 else: 386 raise PermissionExistsError( 387 _("The user %(user)s is already in the group %(group)s.", 388 user=username, group=action))
389
390 - def revoke_permission(self, username, action):
391 """Revokes the permission of the specified user to perform an 392 action.""" 393 self.store.revoke_permission(username, action)
394
395 - def get_actions_dict(self, skip=None):
396 """Get all actions from permission requestors as a `dict`. 397 398 The keys are the action names. The values are the additional actions 399 granted by each action. For simple actions, this is an empty list. 400 For meta actions, this is the list of actions covered by the action. 401 402 :since 1.0.17: added `skip` argument. 403 """ 404 actions = {} 405 for requestor in self.requestors: 406 if requestor is skip: 407 continue 408 for action in requestor.get_permission_actions() or []: 409 if isinstance(action, tuple): 410 actions.setdefault(action[0], []).extend(action[1]) 411 else: 412 actions.setdefault(action, []) 413 return actions
414 415 @lazy
416 - def actions(self):
417 return self.get_actions()
418
419 - def get_actions(self, skip=None):
420 """Get a list of all actions defined by permission requestors.""" 421 actions = set() 422 for requestor in self.requestors: 423 if requestor is skip: 424 continue 425 for action in requestor.get_permission_actions() or []: 426 if isinstance(action, tuple): 427 actions.add(action[0]) 428 else: 429 actions.add(action) 430 return sorted(actions)
431
432 - def get_groups_dict(self):
433 """Get all groups as a `dict`. 434 435 The keys are the group names. The values are the group members. 436 437 :since: 1.1.3 438 """ 439 groups = sorted((p for p in self.get_all_permissions() 440 if not p[1].isupper()), key=lambda p: p[1]) 441 442 return {k: sorted(i[0] for i in list(g)) 443 for k, g in groupby(groups, key=lambda p: p[1])}
444
445 - def get_users_dict(self):
446 """Get all users as a `dict`. 447 448 The keys are the user names. The values are the actions possessed 449 by the user. 450 451 :since: 1.1.3 452 """ 453 perms = sorted((p for p in self.get_all_permissions() 454 if p[1].isupper()), key=lambda p: p[0]) 455 456 return {k: sorted(i[1] for i in list(g)) 457 for k, g in groupby(perms, key=lambda p: p[0])}
458
459 - def get_user_permissions(self, username=None, undefined=False, 460 expand_meta=True):
461 """Return the permissions of the specified user. 462 463 The return value is a dictionary containing all the actions 464 granted to the user mapped to `True`. 465 466 :param undefined: if `True`, include actions that are not defined 467 in any of the `IPermissionRequestor` implementations. 468 :param expand_meta: if `True`, expand meta permissions. 469 470 :since 1.3.1: added the `undefined` parameter. 471 :since 1.3.3: added the `expand_meta` parameter. 472 """ 473 if not username: 474 # Return all permissions available in the system 475 return dict.fromkeys(self.get_actions(), True) 476 477 # Return all permissions that the given user has 478 actions = self.get_actions_dict() 479 user_permissions = self.store.get_user_permissions(username) or [] 480 if expand_meta: 481 return {p: True for p in self.expand_actions(user_permissions) 482 if undefined or p in actions} 483 else: 484 return {p: True for p in user_permissions 485 if undefined or p in actions}
486
487 - def get_permission_groups(self, username):
488 """Return a sorted list of groups that `username` belongs to. 489 490 Groups are recursively expanded such that if `username` is a 491 member of `group1` and `group1` is a member of `group2`, both 492 `group1` and `group2` will be returned. 493 494 :since: 1.3.3 495 """ 496 user_groups = set() 497 for provider in self.group_providers: 498 user_groups.update(provider.get_permission_groups(username) or []) 499 500 return sorted(user_groups)
501
502 - def get_all_permissions(self):
503 """Return all permissions for all users. 504 505 The permissions are returned as a list of (subject, action) 506 formatted tuples. 507 """ 508 return self.store.get_all_permissions() or []
509
510 - def get_users_with_permission(self, permission):
511 """Return all users that have the specified permission. 512 513 Users are returned as a list of user names. 514 """ 515 now = time_now() 516 if now - self.last_reap > self.CACHE_REAP_TIME: 517 self.permission_cache = {} 518 self.last_reap = now 519 timestamp, permissions = self.permission_cache.get(permission, 520 (0, None)) 521 if now - timestamp <= self.CACHE_EXPIRY: 522 return permissions 523 524 parent_map = {} 525 for parent, children in self.get_actions_dict().iteritems(): 526 for child in children: 527 parent_map.setdefault(child, set()).add(parent) 528 529 satisfying_perms = set() 530 def append_with_parents(action): 531 if action not in satisfying_perms: 532 satisfying_perms.add(action) 533 for action in parent_map.get(action, ()): 534 append_with_parents(action)
535 append_with_parents(permission) 536 537 perms = self.store.get_users_with_permissions(satisfying_perms) or [] 538 self.permission_cache[permission] = (now, perms) 539 return perms
540
541 - def expand_actions(self, actions):
542 """Helper method for expanding all meta actions.""" 543 all_actions = self.get_actions_dict() 544 expanded_actions = set() 545 def expand_action(action): 546 if action not in expanded_actions: 547 expanded_actions.add(action) 548 for a in all_actions.get(action, ()): 549 expand_action(a)
550 for a in actions: 551 expand_action(a) 552 return sorted(expanded_actions) 553
554 - def check_permission(self, action, username=None, resource=None, 555 perm=None):
556 """Return True if permission to perform action for the given 557 resource is allowed. 558 """ 559 if username is None: 560 username = 'anonymous' 561 if resource and resource.realm is None: 562 resource = None 563 for policy in self.policies: 564 decision = policy.check_permission(action, username, resource, 565 perm) 566 if decision is not None: 567 self.log.debug("%s %s %s performing %s on %r", 568 policy.__class__.__name__, 569 'allows' if decision else 'denies', 570 username, action, resource) 571 return decision 572 self.log.debug("No policy allowed %s performing %s on %r", 573 username, action, resource) 574 return False
575 576 # IPermissionRequestor methods 577
578 - def get_permission_actions(self):
579 """Implement the global `TRAC_ADMIN` meta permission. 580 """ 581 actions = self.get_actions(skip=self) 582 return [('TRAC_ADMIN', actions)]
583
584 585 -class PermissionCache(object):
586 """Cache that maintains the permissions of a single user. 587 588 Permissions are usually checked using the following syntax: 589 590 'WIKI_MODIFY' in perm 591 592 One can also apply more fine grained permission checks and 593 specify a specific resource for which the permission should be available: 594 595 'WIKI_MODIFY' in perm('wiki', 'WikiStart') 596 597 If there's already a `page` object available, the check is simply: 598 599 'WIKI_MODIFY' in perm(page.resource) 600 601 If instead of a check, one wants to assert that a given permission is 602 available, the following form should be used: 603 604 perm.require('WIKI_MODIFY') 605 606 or 607 608 perm('wiki', 'WikiStart').require('WIKI_MODIFY') 609 610 or 611 612 perm(page.resource).require('WIKI_MODIFY') 613 614 When using `require`, a `PermissionError` exception is raised if the 615 permission is missing. 616 """ 617 618 __slots__ = ('env', 'username', '_resource', '_cache') 619
620 - def __init__(self, env, username=None, resource=None, cache=None, 621 groups=None):
622 self.env = env 623 self.username = username or 'anonymous' 624 self._resource = resource 625 if cache is None: 626 cache = {} 627 self._cache = cache
628
629 - def _normalize_resource(self, realm_or_resource, id, version):
630 if realm_or_resource: 631 return Resource(realm_or_resource, id, version) 632 else: 633 return self._resource
634
635 - def __call__(self, realm_or_resource, id=False, version=False):
636 """Convenience function for using thus: 637 'WIKI_VIEW' in perm(context) 638 or 639 'WIKI_VIEW' in perm(realm, id, version) 640 or 641 'WIKI_VIEW' in perm(resource) 642 643 """ 644 resource = Resource(realm_or_resource, id, version) \ 645 if realm_or_resource else None 646 if resource and self._resource and resource == self._resource: 647 return self 648 else: 649 return PermissionCache(self.env, self.username, resource, 650 self._cache)
651
652 - def has_permission(self, action, realm_or_resource=None, id=False, 653 version=False):
654 resource = self._normalize_resource(realm_or_resource, id, version) 655 return self._has_permission(action, resource)
656
657 - def _has_permission(self, action, resource):
658 key = (self.username, hash(resource), action) 659 cached = self._cache.get(key) 660 if cached: 661 cache_decision, cache_resource = cached 662 if resource == cache_resource: 663 return cache_decision 664 # Avoid recursion in policies that call has_permission. 665 self._cache[key] = (False, resource) 666 perm = self 667 if resource is not self._resource: 668 perm = PermissionCache(self.env, self.username, resource, 669 self._cache) 670 decision = PermissionSystem(self.env). \ 671 check_permission(action, perm.username, resource, perm) 672 self._cache[key] = (decision, resource) 673 return decision
674 675 __contains__ = has_permission 676
677 - def require(self, action, realm_or_resource=None, id=False, version=False, 678 message=None):
679 resource = self._normalize_resource(realm_or_resource, id, version) 680 if not self._has_permission(action, resource): 681 if message is None: 682 raise PermissionError(action, resource, self.env) 683 else: 684 raise PermissionError(msg=message)
685 assert_permission = require
686
687 688 -class PermissionAdmin(Component):
689 """trac-admin command provider for permission system administration.""" 690 691 implements(IAdminCommandProvider) 692 693 # IAdminCommandProvider methods 694
695 - def get_admin_commands(self):
696 yield ('permission list', '[user]', 697 "List permission rules", 698 self._complete_list, self._do_list) 699 yield ('permission add', '<user> <action> [action] [...]', 700 "Add a new permission rule", 701 self._complete_add, self._do_add) 702 yield ('permission remove', '<user> <action> [action] [...]', 703 "Remove a permission rule", 704 self._complete_remove, self._do_remove) 705 yield ('permission export', '[file]', 706 "Export permission rules to a file or stdout as CSV", 707 self._complete_import_export, self._do_export) 708 yield ('permission import', '[file]', 709 "Import permission rules from a file or stdin as CSV", 710 self._complete_import_export, self._do_import)
711
712 - def get_user_list(self):
713 return {user for (user, action) 714 in PermissionSystem(self.env).get_all_permissions()}
715
716 - def get_user_perms(self, user):
717 return [action for (subject, action) 718 in PermissionSystem(self.env).get_all_permissions() 719 if subject == user]
720
721 - def _complete_list(self, args):
722 if len(args) == 1: 723 return self.get_user_list()
724
725 - def _complete_add(self, args):
726 if len(args) == 1: 727 return self.get_user_list() 728 elif len(args) >= 2: 729 return (set(PermissionSystem(self.env).get_actions()) 730 - set(self.get_user_perms(args[0])) - set(args[1:-1]))
731
732 - def _complete_remove(self, args):
733 if len(args) == 1: 734 return self.get_user_list() 735 elif len(args) >= 2: 736 return set(self.get_user_perms(args[0])) - set(args[1:-1])
737
738 - def _complete_import_export(self, args):
739 if len(args) == 1: 740 return get_dir_list(args[-1])
741
742 - def _do_list(self, user=None):
743 permsys = PermissionSystem(self.env) 744 if user: 745 rows = [] 746 perms = permsys.get_user_permissions(user, undefined=True) 747 for action in perms: 748 if perms[action]: 749 rows.append((user, action)) 750 else: 751 rows = permsys.get_all_permissions() 752 rows.sort() 753 print_table(rows, [_('User'), _('Action')]) 754 print() 755 printout(_("Available actions:")) 756 actions = permsys.get_actions() 757 actions.sort() 758 text = ', '.join(actions) 759 printout(wrap(text, initial_indent=' ', subsequent_indent=' ', 760 linesep='\n')) 761 print()
762
763 - def _do_add(self, user, *actions):
764 permsys = PermissionSystem(self.env) 765 if user.isupper(): 766 raise AdminCommandError(_("All upper-cased tokens are reserved " 767 "for permission names")) 768 769 def grant_actions_atomically(actions): 770 action = None 771 try: 772 with self.env.db_transaction: 773 for action in actions: 774 permsys.grant_permission(user, action) 775 except PermissionExistsError as e: 776 printout(e) 777 return action 778 except TracError as e: 779 raise AdminCommandError(e)
780 781 # An exception rolls back the atomic transaction so it's 782 # necessary to retry the transaction after removing the 783 # failed action from the list. 784 actions_to_grant = list(actions) 785 while actions_to_grant: 786 action_that_failed = grant_actions_atomically(actions_to_grant) 787 if action_that_failed is None: 788 break 789 else: 790 actions_to_grant.