diff --git a/README.md b/README.md index 5bd193205..495ed65a1 100644 --- a/README.md +++ b/README.md @@ -2118,6 +2118,66 @@ membership, this can be achieved with another query. `.permissions` was therefore removed in v2.0.0 in favor of `.acls`. +Atomically setting permissions +------------------------------ + +A list of permissions may be added to an object atomically using +the AccessManager's `apply_atomic_operations` method: +```py +from irods.access import ACLOperation +from irods.helpers import home_collection +session = irods.helpers.make_session() +myCollection = session.collections.create(f"{home_collection(session)}/newCollection") + +session.acls.apply_atomic_operations( + myCollection.path, + *[ + ACLOperation("read", "public"), + ACLOperation("write", "bob", "otherZone") + ] +) +``` +`ACLOperation` objects form a linear order with `iRODSAccess` objects, and +indeed are subclassed from them as well, allowing equivalence comparisons and +also permitting intermixed sequences to be sorted (using the `__lt__` method +if no sort `key` parameter is given). + +Care should be taken however to normalize the objects before such comparisons +and sorting, and with connected uses of the `in` operator (which leverages `__eq__`). + +The following code sorts the objects based on their lexical order starting with the +normalized `access_name`, which serves to group identical permissions together: +```py +from irods.access import * +import irods.helpers +acls = [ + iRODSAccess('read_object', '/tempZone/home/alice', 'bob', 'tempZone'), + ACLOperation('write', 'rods'), + ACLOperation('read', 'bob'), +] + +session = irods.helpers.make_session() +normalize = lambda acl: acl.normal(local_zone=session.zone) + +print(normalize(acls[0]) == normalize(acls[2])) +acls.sort(key=normalize) +print(normalize(iRODSAccess('read', '', 'bob')) in map(normalize,acls)) +``` + +If strict order of permissions is desired, we can use code such as the following: +```py +from irods.access import * +from pprint import pp +pp(sorted( + [ + ACLOperation('read', 'bob' ), + ACLOperation('own', 'rods'), + ACLOperation('read_object', 'alice') + ], + key=lambda acl: (all_permissions[acl.access_name], acl.normal()) +)) +``` + Quotas (v2.0.0) --------------- diff --git a/irods/access.py b/irods/access.py index 465585ddc..f95cdca3b 100644 --- a/irods/access.py +++ b/irods/access.py @@ -1,11 +1,67 @@ import collections import copy +import warnings + from irods.collection import iRODSCollection from irods.data_object import iRODSDataObject -from irods.path import iRODSPath + +_permissions = ( + "own", + "delete_object", + "write", + "modify_object", + "create_object", + "delete_metadata", + "modify_metadata", + "create_metadata", + "read", + "read_object", + "read_metadata", + "null", +) class _Access_LookupMeta(type): + @staticmethod + def _codes(): + return collections.OrderedDict( + (key_, value_) + for key_, value_ in sorted( + { + # adapted from iRODS source code in + # ./server/core/include/irods/catalog_utilities.hpp: + "null": 1000, + "execute": 1010, + "read_annotation": 1020, + "read_system_metadata": 1030, + "read_metadata": 1040, + "read_object": 1050, + "write_annotation": 1060, + "create_metadata": 1070, + "modify_metadata": 1080, + "delete_metadata": 1090, + "administer_object": 1100, + "create_object": 1110, + "modify_object": 1120, + "delete_object": 1130, + "create_token": 1140, + "delete_token": 1150, + "curate": 1160, + "own": 1200, + }.items(), + key=lambda _: _[1], + ) + if key_ in _permissions + ) + + @property + def codes(cls): + return cls._codes() + + @property + def strings(cls): + return collections.OrderedDict((number, string) for string, number in cls._codes().items()) + def __getitem__(self, key): return self.codes[key] @@ -19,7 +75,7 @@ def items(self): return list(zip(self.keys(), self.values())) -class iRODSAccess(metaclass=_Access_LookupMeta): +class _iRODSAccess_base: @classmethod def to_int(cls, key): return cls.codes[key] @@ -28,54 +84,7 @@ def to_int(cls, key): def to_string(cls, key): return cls.strings[key] - codes = collections.OrderedDict( - (key_, value_) - for key_, value_ in sorted( - dict( - # copied from iRODS source code in - # ./server/core/include/irods/catalog_utilities.hpp: - null=1000, - execute=1010, - read_annotation=1020, - read_system_metadata=1030, - read_metadata=1040, - read_object=1050, - write_annotation=1060, - create_metadata=1070, - modify_metadata=1080, - delete_metadata=1090, - administer_object=1100, - create_object=1110, - modify_object=1120, - delete_object=1130, - create_token=1140, - delete_token=1150, - curate=1160, - own=1200, - ).items(), - key=lambda _: _[1], - ) - if key_ - in ( - # These are copied from ichmod help text. - "own", - "delete_object", - "write", - "modify_object", - "create_object", - "delete_metadata", - "modify_metadata", - "create_metadata", - "read", - "read_object", - "read_metadata", - "null", - ) - ) - - strings = collections.OrderedDict((number, string) for string, number in codes.items()) - - def __init__(self, access_name, path, user_name="", user_zone="", user_type=None): + def __init__(self, access_name, path, user_name, user_zone, user_type): self.access_name = access_name if isinstance(path, (iRODSCollection, iRODSDataObject)): self.path = path.path @@ -91,27 +100,99 @@ def __init__(self, access_name, path, user_name="", user_zone="", user_type=None self.user_zone = user_zone self.user_type = user_type + def __lt__(self, other): + return (self.access_name, self.user_name, self.user_zone, str(self.path)) < ( + other.access_name, + other.user_name, + other.user_zone, + str(other.path), + ) + def __eq__(self, other): return ( self.access_name == other.access_name - and iRODSPath(self.path) == iRODSPath(other.path) + and str(self.path) == str(other.path) and self.user_name == other.user_name and self.user_zone == other.user_zone ) def __hash__(self): - return hash((self.access_name, iRODSPath(self.path), self.user_name, self.user_zone)) + return hash((self.access_name, str(self.path), self.user_name, self.user_zone)) + + def normal(self, local_zone=""): + """ + Create a normalized version of the object for comparison in sorting or determining equavalence. - def copy(self, decanonicalize=False): + Args: + local_zone: the name of the home zone, if any, in which client user directly authenticates. + The purpose is zone name normalization; if this parameter is a nonzero-length string which + matches the zone_name in the source object, the copy will contain a null zone_name field. + + Returns: + The normalized copy of the source object. In practice, this will be an ACLOperation or iRODSAccess + object, according to the type of the source object. + """ + normal_form = self.copy(decanonicalize=-1, implied_zone=local_zone) + normal_form.path = "" + return normal_form + + def copy(self, decanonicalize=False, implied_zone=''): + """ + Create a copy of the object, possibly in a normalized form. + + Args: + decanonicalize: Whether to modify to access_name field to a more human-readable form + (when 1 or True) or a more standard form (when -1). If the former, then a more + organic style is favored, i.e. "read" and "write". If the latter, the new + access_name will be more machine-friendly for operators __lt__ (for sorting) and + __eq__ (for equivalence or use with 'in'). If equal to 0 (or False), no adjustment + is done. + implied_zone: If a nonzero-length name, compare this against the zone_name field of the + old object, and if they match, force the zone_name to zero-length in the new object. + + Returns: + A copy of the invoking object, normalized if requested. + + Raises: + RuntimeError: if decanonicalize parameter is not one of {-1,0,False,1,True}. + """ other = copy.deepcopy(self) - if decanonicalize: - replacement_string = { - "read object": "read", - "read_object": "read", - "modify object": "write", - "modify_object": "write", - }.get(self.access_name) - other.access_name = replacement_string if replacement_string is not None else self.access_name + + access_name = self.access_name + + if decanonicalize == 1: + if ( + new_access_name := { + "read object": "read", + "read_object": "read", + "modify object": "write", + "modify_object": "write", + }.get(access_name) + ) is not None: + access_name = new_access_name + elif decanonicalize == -1: + # Canonicalize, ie. change out old access_name for an unambiguous "standard" value. + access_name = access_name.replace(" ", "_") + if ( + new_access_name := { + "read": "read_object", + "write": "modify_object", + }.get(access_name) + ) is not None: + access_name = new_access_name + elif decanonicalize == 0: + pass + else: + msg = "Improper value for 'decanonicalize' parameter" + raise RuntimeError(msg) + + other.access_name = access_name + + # Useful if we wish to force an explicitly specified local zone to an implicit zone spec in the copy, for + # equality testing: + if '' != implied_zone == other.user_zone: + other.user_zone = '' + return other def __repr__(self): @@ -121,10 +202,110 @@ def __repr__(self): return f"" -class _iRODSAccess_pre_4_3_0(iRODSAccess): - codes = collections.OrderedDict( - (key.replace("_", " "), value) - for key, value in iRODSAccess.codes.items() - if key in ("own", "write", "modify_object", "read", "read_object", "null") - ) - strings = collections.OrderedDict((number, string) for string, number in codes.items()) +class iRODSAccess(_iRODSAccess_base, metaclass=_Access_LookupMeta): + """ + Represents an ACL in iRODS. An instance of this class functions as a data container + to convey information to the iRODS server (in the `set` call) and back again to the client + again (in the `get` call). + """ + + def __init__(self, access_name, path, user_name="", user_zone="", user_type=None): + self.codes = self.__class__.codes + self.strings = self.__class__.strings + super().__init__(access_name, path, user_name, user_zone, user_type) + + +class ACLOperation(iRODSAccess): + """ + Similar to its base class iRODSAccess, this class represents an ACL to be set on an object. + but this class is the counterpart used for the atomic ACLs api. It differs from its base + class in that it has no field to store a logical object path. (For an atomic API call, i + here is always a single logical path to which all operations apply, meaning that it is + appropriate to conveyed that in a location separate from the operations themselves.) + """ # noqa: D400 + + def __init__(self, access_name: str, user_name: str = "", user_zone: str = ""): + super().__init__( + access_name=access_name, + path="", + user_name=user_name, + user_zone=user_zone, + ) + + def __eq__(self, other): + return ( + self.access_name, + self.user_name, + self.user_zone, + ) == ( + other.access_name, + other.user_name, + other.user_zone, + ) + + def __hash__(self): + # Hash in a way consistent with an iRODSAccess having path "". + return hash(( + self.access_name, + "", # path + self.user_name, + self.user_zone, + )) + + def __lt__(self, other): + return ( + self.access_name, + self.user_name, + self.user_zone, + ) < ( + other.access_name, + other.user_name, + other.user_zone, + ) + + def __repr__(self): + return f"" + + +( + _synonym_mapping := { + "write": "modify_object", + "read": "read_object", + } +).update((key.replace("_", " "), key) for key in iRODSAccess.codes) + + +all_permissions = { + **iRODSAccess.codes, + **{key: iRODSAccess.codes[_synonym_mapping[key]] for key in _synonym_mapping}, +} + +canonical_permissions = {k: v for k, v in all_permissions.items() if ' ' not in k and k not in ('read', 'write')} + + +class _deprecated: + class _iRODSAccess_pre_4_3_0(_iRODSAccess_base): + codes = collections.OrderedDict( + (key.replace("_", " "), value) + for key, value in iRODSAccess.codes.items() + if key in ("own", "write", "modify_object", "read", "read_object", "null") + ) + strings = collections.OrderedDict((number, string) for string, number in codes.items()) + + def __init__(self, *args, **kwargs): + warnings.warn( + "_iRODSAccess_pre_4_3_0 is deprecated and will be removed in a future version. Use iRODSAccess instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + + +_deprecated_names = {'_iRODSAccess_pre_4_3_0': _deprecated._iRODSAccess_pre_4_3_0} + + +def __getattr__(name): + if name in _deprecated_names: + warnings.warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) + return _deprecated_names[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/irods/api_number.py b/irods/api_number.py index fe614ffce..03ac3de86 100644 --- a/irods/api_number.py +++ b/irods/api_number.py @@ -177,6 +177,7 @@ "ATOMIC_APPLY_METADATA_OPERATIONS_APN": 20002, "GET_FILE_DESCRIPTOR_INFO_APN": 20000, "REPLICA_CLOSE_APN": 20004, + "ATOMIC_APPLY_ACL_OPERATIONS_APN": 20005, "TOUCH_APN": 20007, "AUTH_PLUG_REQ_AN": 1201, "AUTHENTICATION_APN": 110000, diff --git a/irods/exception.py b/irods/exception.py index b9551fdc1..4dbbd66de 100644 --- a/irods/exception.py +++ b/irods/exception.py @@ -650,6 +650,10 @@ class SYS_INVALID_INPUT_PARAM(SystemException): code = -130000 +class SYS_INTERNAL_ERR(SystemException): + code = -154000 + + class SYS_BAD_INPUT(iRODSException): code = -158000 diff --git a/irods/manager/access_manager.py b/irods/manager/access_manager.py index bf32dc283..1b0828e9a 100644 --- a/irods/manager/access_manager.py +++ b/irods/manager/access_manager.py @@ -1,25 +1,23 @@ +import logging from os.path import basename, dirname -from irods.manager import Manager +from irods.access import iRODSAccess from irods.api_number import api_number -from irods.message import ModAclRequest, iRODSMessage -from irods.data_object import iRODSDataObject, irods_dirname, irods_basename from irods.collection import iRODSCollection +from irods.column import In +from irods.data_object import irods_basename, irods_dirname, iRODSDataObject +from irods.manager import Manager +from irods.message import JSON_Message, ModAclRequest, iRODSMessage from irods.models import ( - DataObject, Collection, - User, + CollectionAccess, CollectionUser, DataAccess, - CollectionAccess, + DataObject, + User, ) -from irods.access import iRODSAccess -from irods.column import In from irods.user import iRODSUser -import logging -import warnings - logger = logging.getLogger(__name__) @@ -36,6 +34,32 @@ def users_by_ids(session, ids=()): class AccessManager(Manager): + @staticmethod + def _to_acl_operation_json(op_input: iRODSAccess): + return { + "acl": op_input.access_name, + "entity_name": op_input.user_name, + **({} if not (z := op_input.user_zone) else {"zone": z}), + } + + def apply_atomic_operations(self, logical_path: str, *operations, admin=False): + request_text = { + "logical_path": logical_path, + "admin_mode": admin, + "operations": [self._to_acl_operation_json(op) for op in operations], + } + + with self.sess.pool.get_connection() as conn: + request_msg = iRODSMessage( + "RODS_API_REQ", + JSON_Message(request_text, conn.server_version), + int_info=api_number["ATOMIC_APPLY_ACL_OPERATIONS_APN"], + ) + conn.send(request_msg) + response = conn.recv() + response_msg = response.get_json_encoded_struct() + logger.debug("in atomic ACL api, server responded with: %r", response_msg) + def get(self, target, report_raw_acls=True, **kw): if report_raw_acls: @@ -148,7 +172,7 @@ def set(self, acl, recursive=False, admin=False, **kw): zone_ = acl.user_zone if acl.access_name.endswith("inherit"): zone_ = userName_ = "" - acl = acl.copy(decanonicalize=True) + acl = acl.copy(decanonicalize=-1) message_body = ModAclRequest( recursiveFlag=int(recursive), accessLevel=f"{prefix}{acl.access_name}", diff --git a/irods/session.py b/irods/session.py index 0b32a01f1..1f8d8305e 100644 --- a/irods/session.py +++ b/irods/session.py @@ -106,12 +106,17 @@ def auth_file(self): @property def available_permissions(self): - from irods.access import iRODSAccess, _iRODSAccess_pre_4_3_0 - try: self.__access except AttributeError: - self.__access = _iRODSAccess_pre_4_3_0 if self.server_version < (4, 3) else iRODSAccess + if self.server_version < (4, 3): + from irods.access import _iRODSAccess_pre_4_3_0 + + self.__access = _iRODSAccess_pre_4_3_0 + else: + from irods.access import iRODSAccess + + self.__access = iRODSAccess return self.__access def __init__(self, configure=True, auto_cleanup=True, **kwargs): diff --git a/irods/test/access_test.py b/irods/test/access_test.py index fadc6a7dc..d6be21f1a 100644 --- a/irods/test/access_test.py +++ b/irods/test/access_test.py @@ -4,15 +4,15 @@ import sys import unittest -from irods.access import iRODSAccess +from irods.access import ACLOperation, iRODSAccess from irods.collection import iRODSCollection from irods.column import In, Like from irods.exception import UserDoesNotExist -from irods.models import User, Collection, DataObject +from irods.models import Collection, DataObject, User from irods.path import iRODSPath -from irods.user import iRODSUser from irods.session import iRODSSession -import irods.test.helpers as helpers +from irods.test import helpers +from irods.user import iRODSUser class TestAccess(unittest.TestCase): @@ -497,6 +497,43 @@ def test_iRODSAccess_cannot_be_constructed_using_unsupported_type__issue_558(sel self.sess, ) + def test_atomic_acls__issue_505(self): + ses = self.sess + zone = user1 = user2 = user3 = group = None + try: + zone = ses.zones.create("twilight", "remote") + user1 = ses.users.create("test_user_505", "rodsuser") + user2 = ses.users.create("rod_serling_505#twilight", "rodsuser") + user3 = ses.users.create("local_test_user_505", "rodsuser") + group = ses.groups.create("test_group_505") + ses.acls.apply_atomic_operations( + self.coll_path, + a1 := ACLOperation("write", user1.name, user1.zone), + a2 := ACLOperation("read", user2.name, user2.zone), + a3 := ACLOperation("read", user3.name), + a4 := ACLOperation("read", group.name), + ) + + accesses = {acl.normal(ses.zone) for acl in ses.acls.get(self.coll)} + + # Assert that the ACLs we added are among those listed for the object in the catalog. + self.assertIn(a1.normal(ses.zone), accesses) + self.assertIn(a2.normal(ses.zone), accesses) + self.assertIn(a3.normal(ses.zone), accesses) + self.assertIn(a4.normal(ses.zone), accesses) + + finally: + if user1: + user1.remove() + if user2: + user2.remove() + if user3: + user3.remove() + if group: + group.remove() + if zone: + zone.remove() + if __name__ == "__main__": # let the tests find the parent irods lib