diff --git a/irods/access.py b/irods/access.py index 1ad6660a7..27e0f061f 100644 --- a/irods/access.py +++ b/irods/access.py @@ -20,7 +20,6 @@ "null", ) - class _Access_LookupMeta(type): @staticmethod def _codes(): diff --git a/irods/manager/access_manager.py b/irods/manager/access_manager.py index 5873c1e15..e71a8c91c 100644 --- a/irods/manager/access_manager.py +++ b/irods/manager/access_manager.py @@ -2,12 +2,14 @@ import logging from os.path import basename, dirname +from typing import Union, NamedTuple from irods.access import iRODSAccess from irods.api_number import api_number from irods.collection import iRODSCollection from irods.column import In from irods.data_object import irods_basename, irods_dirname, iRODSDataObject +from irods.exception import CAT_NO_ROWS_FOUND from irods.manager import Manager from irods.message import JSON_Message, ModAclRequest, iRODSMessage from irods.models import ( @@ -16,9 +18,12 @@ CollectionUser, DataAccess, DataObject, + Group, User, ) -from irods.user import iRODSUser +from irods.query import SpecificQuery +from irods.user import iRODSUser, iRODSGroup + logger = logging.getLogger(__name__) @@ -35,7 +40,138 @@ def users_by_ids(session, ids=()): return [iRODSUser(session.users, i) for i in session.query(User.id, User.name, User.type, User.zone).filter(*cond)] +class QueryParameterError(ValueError): + pass + +# This is the alias where we expect to find a specific query +alias_for_get_maximum_permission_for_path = 'get_maximum_permission_for_path' + +class _iRODSPermission(NamedTuple): + as_string: str + as_integer: int + class AccessManager(Manager): + def get_maximum_permission_for_path(self, + user : Union[iRODSUser, iRODSGroup], + coll : Union[str, iRODSCollection, None]=None, + data : Union[str, iRODSDataObject, None]=None, + specific_query : Union[SpecificQuery, str, None]='', + ) -> _iRODSPermission: + """ + Return the highest permission the named user/group has with respect to the + given data object or collection. In calculating this result, all ACLs and + group memberships are factored in. + + Only one of 'data' or 'coll' may be specified. + + Args: + user: an iRODSUser or iRODSGroup object for whom the permission is to be calculated. + coll: an iRODS logical path or iRODSCollection. + data: an iRODS logical path or iRODSDataObject. + specific_query (default ""): a SpecificQuery object or an alias specifying + the specific query used for the calculation. The default value of "" will be + interpreted to mean the designated specific query preconfigured for this express + purpose. If the server does not cache a query under that alias, or if None is + given as a value, then the calculation will be done via a general query. + + Returns: + a named tuple representing the permission results in terms of both the symbolic + string (.as_string) and integer code (.as_integer). If no ACLs + exist granting permission for the named user or group, then None is returned. + """ + ses = self.sess + + # Convert user or group to object. Need to know if we are dealing with a rodsadmin + if isinstance(user, (iRODSUser, iRODSGroup)): + pass + else: + msg = "'user' argument should be of type iRODSUser or iRODSGroup." + raise QueryParameterError(msg) + + _specific_query = None + + if specific_query == '': + specific_query = alias_for_get_maximum_permission_for_path + + if isinstance(specific_query, SpecificQuery): + _specific_query = specific_query + elif isinstance(specific_query, str): + _specific_query = SpecificQuery(ses, alias=specific_query) + logger=logging.getLogger('issue_561_query_type_detection') + # Dispatch to the available/chosen query method. + if _specific_query is not None: + logger.debug('SPECIFIC') + def denullify(s): + return s if s else "x" + _specific_query.reset_for_new_query( + args=[ + user.name, + getattr(user,'zone',ses.zone), + denullify(coll.path if isinstance(coll, iRODSCollection) else coll), + denullify(data.path if isinstance(data, iRODSDataObject) else data), + ] + ) + max_permission = self._get_maximum_permission_for_path_via_SpecificQuery(_specific_query) + res = [max_permission] + else: + logger.debug('GENERAL') + res = None + groups = {g[Group.id] for g in ses.query(Group, User).filter(Group.id != user.id, User.id == user.id)} + groups |= {user.id} + + if data: + if isinstance(data, str): + collN = irods_dirname(data) + dataN = irods_basename(data) + join_criteria = [DataObject.name == dataN, Collection.name == collN] + elif isinstance(data, iRODSDataObject): + join_criteria = [ DataAccess.data_id == data.id] + else: + msg="'data' parameter must be full logical path or data object" + raise ValueError(msg) + + q = ses.query(DataAccess.type).filter( + In(DataAccess.user_id, groups), + *join_criteria + ).order_by(DataAccess.type,order='desc') + + res = [_[DataAccess.type] for _ in q] + + elif coll: + if isinstance(coll, str): + join_criteria = [Collection.name == coll] + elif isinstance(coll, iRODSCollection): + join_criteria = [CollectionAccess.access_id == coll.id] # TODO: access_id is not an accurate name, should be coll_id + else: + msg="'coll' parameter must be full logical path or collection object" + raise ValueError(msg) + + q=ses.query(CollectionAccess).filter( + In(CollectionAccess.user_id, groups), + *join_criteria + ).order_by(CollectionAccess.type,order='desc') + + res = [_[CollectionAccess.type] for _ in q] + + else: + msg = 'Need a collection or data object' + raise QueryParameterError(msg) + + res = res[0] if res else None + return _iRODSPermission(iRODSAccess.strings[res], as_integer=res) if isinstance(res, int) else None + + @staticmethod + def _get_maximum_permission_for_path_via_SpecificQuery(spec_query): + try: + row = next(iter( + spec_query.execute() + )) + except (StopIteration, CAT_NO_ROWS_FOUND): + row = {} + + highest_perm = row.get(0) + return None if highest_perm is None else int(highest_perm) + @staticmethod def _to_acl_operation_json(op_input: iRODSAccess): return { diff --git a/irods/manager/user_manager.py b/irods/manager/user_manager.py index 50b35ecae..dcfde9d4a 100644 --- a/irods/manager/user_manager.py +++ b/irods/manager/user_manager.py @@ -16,6 +16,7 @@ UserDoesNotExist, GroupDoesNotExist, NoResultFound, + MultipleResultsFound, CAT_SQL_ERR, ) from irods.api_number import api_number @@ -281,12 +282,19 @@ def get__group_create__user_type__default(session): class GroupManager(UserManager): def get(self, name, user_zone=""): + if user_zone not in ("", local_zone:=self.sess.zone): + msg = 'Specified user_zone must be local, since remote groups are not listed locally.' + raise GroupDoesNotExist(msg) + query = self.sess.query(Group).filter(Group.name == name) try: result = query.one() + except MultipleResultsFound: + result = query.filter(User.name == name, User.zone == local_zone).one() except NoResultFound: raise GroupDoesNotExist() + return iRODSGroup(self, result) def _api_info(self, group_admin_flag): diff --git a/irods/query.py b/irods/query.py index aa6e7a514..cce5e92e4 100644 --- a/irods/query.py +++ b/irods/query.py @@ -1,4 +1,5 @@ from collections import OrderedDict +import uuid from irods import MAX_SQL_ROWS from irods.models import Model @@ -290,6 +291,11 @@ def __init__(self, sess, sql=None, alias=None, columns=None, args=None): self._columns = columns self._args = args or [] + def reset_for_new_query(self, args=None): + self._continue_index = 0 + if args is not None: + self._args = args + def register(self): if not self._sql: raise ValueError("Empty query") @@ -363,6 +369,8 @@ def get_results(self): yield result + + # Record a copy of the original value of IRODS_QUERY_LIMIT for possible future access. import irods.helpers as helpers diff --git a/irods/test/access_test.py b/irods/test/access_test.py index 142bb2f5c..7bf0c1133 100644 --- a/irods/test/access_test.py +++ b/irods/test/access_test.py @@ -1,19 +1,27 @@ #! /usr/bin/env python +import io +import logging import os import sys +import textwrap import unittest +import uuid +import irods.access from irods.access import ACLOperation, iRODSAccess from irods.collection import iRODSCollection from irods.column import In, Like -from irods.exception import UserDoesNotExist +from irods.exception import CAT_NO_ROWS_FOUND, UserDoesNotExist from irods.models import Collection, DataObject, User from irods.path import iRODSPath +from irods.query import SpecificQuery from irods.session import iRODSSession from irods.test import helpers from irods.user import iRODSUser +from irods.manager.access_manager import alias_for_get_maximum_permission_for_path + class TestAccess(unittest.TestCase): def setUp(self): @@ -532,6 +540,124 @@ def test_atomic_acls__issue_505(self): if zone: zone.remove() + def test_get_maximum_permission_for_path__issue_561(self): + SQL = textwrap.dedent(''' + WITH target_user AS ( + SELECT user_id + FROM r_user_main + WHERE user_name = ? + AND zone_name = ? + ), + resolved_object AS ( + -- The path names a collection + SELECT coll_id AS object_id + FROM r_coll_main + WHERE coll_name = ? + + UNION + + -- The path names a data object: collection path + '/' + data name + SELECT d.data_id AS object_id + FROM r_data_main AS d + JOIN r_coll_main AS c ON c.coll_id = d.coll_id + WHERE CONCAT(c.coll_name, '/', d.data_name) = ? + ), + grants AS ( + -- Direct grants + SELECT a.access_type_id + FROM r_objt_access AS a + JOIN target_user AS u ON u.user_id = a.user_id + JOIN resolved_object AS o ON o.object_id = a.object_id + + UNION ALL + + -- Grants to groups the user belongs to + SELECT a.access_type_id + FROM r_user_group AS ug + JOIN target_user AS u ON u.user_id = ug.user_id + JOIN r_objt_access AS a ON a.user_id = ug.group_user_id + JOIN resolved_object AS o ON o.object_id = a.object_id + ) + SELECT access_type_id + FROM grants + ORDER BY access_type_id DESC + LIMIT 1;''' + ) + user = group = coll = None + ses = self.sess + my_specific_query=None + query_alias = None + try: + coll = ses.collections.create('/tempZone/home/public/folder_561') + data = ses.data_objects.create('/tempZone/home/public/folder_561/file') + user = ses.users.create('bob','rodsuser') + group = ses.groups.create('lab') + + # Set up test scenario with a new user and group, the group having deliberately higher permission + # so as to be able to account for the effect of group membership in the query results. + USER_PERM = 'modify_metadata' + GROUP_PERM = 'modify_object' + test_perms = { user.name: USER_PERM, group.name: GROUP_PERM } + for obj in (coll, data): + ses.acls.set(iRODSAccess(USER_PERM, obj.path, user.name)) + ses.acls.set(iRODSAccess(GROUP_PERM, obj.path, group.name)) + + sql_arg = {} + try: + SpecificQuery(ses, + alias='findQueryByAlias', + args=(query_alias:=alias_for_get_maximum_permission_for_path,)).execute() + except CAT_NO_ROWS_FOUND: + query_alias = str(uuid.uuid1()), + sql_arg = {'sql':SQL} + + my_specific_query = SpecificQuery(ses, alias=query_alias, **sql_arg) + if sql_arg: + my_specific_query.register() + + logger=logging.getLogger('issue_561_query_type_detection') + logger.setLevel(logging.DEBUG) + for is_member in (False, True): + if is_member: + group.addmember(user.name,user.zone) + for usr in (user, group): + max_user = usr if usr not in group.members else group + expected_permission = test_perms[max_user.name] + for obj_type, obj in ( + ("coll", coll), + ("coll", coll.path), + ("data", data), + ("data", data.path), + ): + query_options = { + 'GENERAL':{'specific_query':None}, + 'SPECIFIC':({} if not my_specific_query else {'specific_query':my_specific_query}) + } + for query_index in ('SPECIFIC', 'GENERAL'): + with self.subTest(f"Issue 561 subTest: {is_member=} {usr=} {obj=} {query_index=}"): + try: + logger.addHandler(handler:=logging.StreamHandler(stream:=io.StringIO())) + permission = ses.acls.get_maximum_permission_for_path( + usr, + **query_options[query_index], + **{obj_type: obj}, + ).as_string + self.assertEqual(permission, expected_permission) + # Check logging output to verify which code path was taken. + self.assertEqual(stream.getvalue().rstrip(), query_index) + finally: + logger.removeHandler(handler) + finally: + if coll: + # Removes test data object as well. + coll.remove(force=True, recurse=True) + if user: + user.remove() + if group: + group.remove() + if my_specific_query and (query_alias != alias_for_get_maximum_permission_for_path): + my_specific_query.remove() + if __name__ == "__main__": # let the tests find the parent irods lib diff --git a/irods/user.py b/irods/user.py index 4312eef64..849c0e8dd 100644 --- a/irods/user.py +++ b/irods/user.py @@ -17,6 +17,14 @@ def remove_quota(self, resource="total"): def set_quota(self, amount, resource="total"): self.manager.set_quota(self.name, amount, resource=resource) + # TODO: add issue for iRODSUser & iRODSGroup equality operators + def __eq__(self, other_user): + # For the case of (iRODSUser_instance == iRODSGroup_instance), iRODSGroup.__eq__ is the fallback + # chosen by the Python interpreter when NotImplemented is returned. + if not isinstance(other_user, iRODSUser): + return NotImplemented + return (self.name, self.zone) == (other_user.name, other_user.zone) + def __init__(self, manager, result=None): self.manager = manager if result: @@ -82,6 +90,15 @@ def remove_quota(self, resource="total"): def set_quota(self, amount, resource="total"): self.manager.set_quota(self.name, amount, resource=resource) + @property + def zone(self): + return self.manager.sess.zone + + def __eq__(self, other_group): + if not isinstance(other_group, (iRODSUser,iRODSGroup)): + return NotImplemented + return (self.name, self.zone) == (other_group.name, other_group.zone) + def __init__(self, manager, result=None): self.manager = manager if result: