From c9bd7a057da90678125dc24257f78f5cd635b3d5 Mon Sep 17 00:00:00 2001 From: udir Date: Wed, 11 Feb 2026 11:09:12 +0200 Subject: [PATCH 1/8] SDK-348 - Add unit test for handling `completed_with_warnings` in file move operations - Simplify `_parents_generator` logic, removing strict permission argument from directory creation commands - Add unit test for strict permission directory creation at root paths --- cterasdk/cio/core/commands.py | 21 +++------------------ tests/ut/core/user/test_mkdir.py | 7 +++++++ tests/ut/core/user/test_move.py | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/cterasdk/cio/core/commands.py b/cterasdk/cio/core/commands.py index 6fb55acc..cc83a442 100644 --- a/cterasdk/cio/core/commands.py +++ b/cterasdk/cio/core/commands.py @@ -685,17 +685,7 @@ def _before_command(self): def _parents_generator(self): if self.parents: parts = self.path.parts - start_index = 1 - known_roots = ('My Files', 'Shared With Me', 'Shared', 'Team Portal') - if parts: - if parts[0] in known_roots: - start_index = 2 - elif parts[0] in ('Users', 'Groups') and len(parts) > 1: - if len(parts) > 2 and parts[2] in known_roots: - start_index = 4 - else: - start_index = 3 - for i in range(start_index, len(parts)): + for i in range(1, len(parts)): yield automatic_resolution('/'.join(parts[:i]), self._receiver.context) else: yield self.path @@ -707,8 +697,7 @@ def _execute(self): CreateDirectory( self._function, self._receiver, - path, - strict_permission=self._strict_permission + path ).execute() except exceptions.io.core.CreateDirectoryError as e: CreateDirectory._suppress_file_conflict_error(e) @@ -722,8 +711,7 @@ async def _a_execute(self): await CreateDirectory( self._function, self._receiver, - path, - strict_permission=self._strict_permission + path ).a_execute() except exceptions.io.core.CreateDirectoryError as e: CreateDirectory._suppress_file_conflict_error(e) @@ -1043,9 +1031,6 @@ def _handle_response(self, r): if r.completed: return self._task_complete(r) - if r.completed_with_warnings: - return r - if r.failed or r.completed_with_warnings: return self._task_error(r) diff --git a/tests/ut/core/user/test_mkdir.py b/tests/ut/core/user/test_mkdir.py index 4af36857..f89e19dc 100644 --- a/tests/ut/core/user/test_mkdir.py +++ b/tests/ut/core/user/test_mkdir.py @@ -35,3 +35,10 @@ def test_mkdir_strict_permission_denied(self): self._init_services(execute_response=execute_response) with self.assertRaises(exceptions.io.core.PrivilegeError): self._services.files.mkdir(self._directory, strict_permission=True) + + def test_makedirs_strict_permission_root_path(self): + rooted_directories = 'Team Portal/Engineering/Documents' + self._init_services(execute_response=None) + ret = self._services.files.makedirs(rooted_directories, strict_permission=True) + self.assertEqual(self._services.api.execute.call_count, len(rooted_directories.split('/'))) + self.assertEqual(ret, rooted_directories) diff --git a/tests/ut/core/user/test_move.py b/tests/ut/core/user/test_move.py index d35a03f9..fe220dbd 100644 --- a/tests/ut/core/user/test_move.py +++ b/tests/ut/core/user/test_move.py @@ -32,6 +32,21 @@ def test_move_strict_permission_denied(self): self._services.files.move(self._source, destination=self._dest, strict_permission=True) self._services.tasks.wait.assert_called_once_with(self._task_reference) + def test_move_completed_with_warnings_raises_move_error(self): + task = mock.MagicMock() + task.completed = False + task.completed_with_warnings = True + task.failed = False + task.cursor = None + task.error_type = None + task.unknown_object.return_value = True + task.progress_str = None + self._services.tasks.wait = mock.MagicMock(return_value=task) + self._init_services(execute_response=self._task_reference) + with self.assertRaises(exceptions.io.core.MoveError): + self._services.files.move(self._source, destination=self._dest, strict_permission=True) + self._services.tasks.wait.assert_called_once_with(self._task_reference) + def _create_move_resource_param(self): destinations = [base_user.BaseCoreServicesTest.encode_path(self._dest + '/' + self._filename)] return self._create_action_resource_param([base_user.BaseCoreServicesTest.encode_path(self._source)], destinations) From 32e76a03cdc70d68ed95d69f5504ed125a65c483 Mon Sep 17 00:00:00 2001 From: udir Date: Wed, 11 Feb 2026 14:53:22 +0200 Subject: [PATCH 2/8] SDK-348 Add tests for handling strict permission errors during file operations and simplify permission error handling logic --- cterasdk/cio/core/commands.py | 66 ++++++++++++++++---------------- tests/ut/core/user/test_mkdir.py | 6 +++ tests/ut/core/user/test_move.py | 8 ++++ 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/cterasdk/cio/core/commands.py b/cterasdk/cio/core/commands.py index cc83a442..4bc0ee78 100644 --- a/cterasdk/cio/core/commands.py +++ b/cterasdk/cio/core/commands.py @@ -40,54 +40,56 @@ def _extract_rc_msg(result): return getattr(result, 'rc', None), getattr(result, 'msg', None) -_STRICT_PERMISSION_ERROR_SET = { - (None, None), - (None, ''), - (0, None), - ('0', None), - (None, 'permission denied'), - (None, 'access denied'), - (None, 'read only'), - (None, 'action is not allowed'), - ('permissiondenied', None), - (None, 'permissiondenied'), - ('permissiondenied', 'permissiondenied'), +_STRICT_PERMISSION_DENIED_MESSAGES = { + 'permission denied', + 'access denied', + 'read only', + 'action is not allowed', } -_STRICT_PERMISSION_TASK_ERROR_SET = { - (None, None, 'permissiondenied'), - (None, None, 'permission denied'), - (None, None, 'access denied'), - (None, None, 'read only'), - (None, None, 'action is not allowed'), - (None, 'permission denied', None), - (None, 'access denied', None), - (None, 'read only', None), - (None, 'action is not allowed', None), - (0, None, 'permissiondenied'), - ('0', None, 'permissiondenied'), -} +# Some APIs return condensed "permissiondenied" in rc/msg/error_type. +_STRICT_PERMISSION_DENIED_TOKENS = {'permissiondenied'} +_STRICT_PERMISSION_AMBIGUOUS_RC = {None, 0, '0'} +_STRICT_PERMISSION_EMPTY_MESSAGES = {None, ''} def _raise_strict_permission_denied(result, path): - rc, msg = _extract_rc_msg(result) - rc = _normalize_rc(rc) - msg = _normalize_msg(msg) + rc, msg = _extract_normalized_rc_msg(result) logger.info( 'strict_permission response for %s: rc=%r msg=%r raw=%s', path, rc, msg, type(result).__name__ ) - if (rc, msg) in _STRICT_PERMISSION_ERROR_SET: + if _is_strict_permission_denied_response(rc, msg): raise exceptions.io.core.PrivilegeError(path) -def _extract_task_error_tuple(result): +def _extract_normalized_rc_msg(result): + rc, msg = _extract_rc_msg(result) + return _normalize_rc(rc), _normalize_msg(msg) + + +def _extract_task_error_fields(result): rc = _normalize_rc(getattr(result, 'rc', None)) msg = _normalize_msg(getattr(result, 'msg', None)) error_type = _normalize_msg(getattr(result, 'error_type', None)) return rc, msg, error_type +def _is_strict_permission_denied_response(rc, msg): + if msg in _STRICT_PERMISSION_DENIED_MESSAGES: + return True + if rc in _STRICT_PERMISSION_DENIED_TOKENS or msg in _STRICT_PERMISSION_DENIED_TOKENS: + return True + return rc in _STRICT_PERMISSION_AMBIGUOUS_RC and msg in _STRICT_PERMISSION_EMPTY_MESSAGES + + +def _is_strict_permission_denied_task(rc, msg, error_type): + _ = rc + if error_type in _STRICT_PERMISSION_DENIED_MESSAGES or error_type in _STRICT_PERMISSION_DENIED_TOKENS: + return True + return msg in _STRICT_PERMISSION_DENIED_MESSAGES + + def split_file_directory(listdir, receiver, destination): """ Split a path into its parent directory and final component. @@ -1022,8 +1024,8 @@ async def _a_execute(self): def _handle_response(self, r): if self._strict_permission: - rc, msg, error_type = _extract_task_error_tuple(r) - if (rc, msg, error_type) in _STRICT_PERMISSION_TASK_ERROR_SET: + rc, msg, error_type = _extract_task_error_fields(r) + if _is_strict_permission_denied_task(rc, msg, error_type): raise exceptions.io.core.PrivilegeError('') if not self.block: return r diff --git a/tests/ut/core/user/test_mkdir.py b/tests/ut/core/user/test_mkdir.py index f89e19dc..901f6aca 100644 --- a/tests/ut/core/user/test_mkdir.py +++ b/tests/ut/core/user/test_mkdir.py @@ -36,6 +36,12 @@ def test_mkdir_strict_permission_denied(self): with self.assertRaises(exceptions.io.core.PrivilegeError): self._services.files.mkdir(self._directory, strict_permission=True) + def test_mkdir_strict_permission_denied_message(self): + execute_response = Object(msg='access denied') + self._init_services(execute_response=execute_response) + with self.assertRaises(exceptions.io.core.PrivilegeError): + self._services.files.mkdir(self._directory, strict_permission=True) + def test_makedirs_strict_permission_root_path(self): rooted_directories = 'Team Portal/Engineering/Documents' self._init_services(execute_response=None) diff --git a/tests/ut/core/user/test_move.py b/tests/ut/core/user/test_move.py index fe220dbd..6e5e2985 100644 --- a/tests/ut/core/user/test_move.py +++ b/tests/ut/core/user/test_move.py @@ -32,6 +32,14 @@ def test_move_strict_permission_denied(self): self._services.files.move(self._source, destination=self._dest, strict_permission=True) self._services.tasks.wait.assert_called_once_with(self._task_reference) + def test_move_strict_permission_denied_error_type(self): + task = Object(error_type='permissiondenied') + self._services.tasks.wait = mock.MagicMock(return_value=task) + self._init_services(execute_response=self._task_reference) + with self.assertRaises(exceptions.io.core.PrivilegeError): + self._services.files.move(self._source, destination=self._dest, strict_permission=True) + self._services.tasks.wait.assert_called_once_with(self._task_reference) + def test_move_completed_with_warnings_raises_move_error(self): task = mock.MagicMock() task.completed = False From 1a018cfd1f854e36089331b6656e9216e281ad29 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Wed, 18 Feb 2026 18:00:06 -0500 Subject: [PATCH 3/8] Capture logged-in user's permissions for accessing user folders Add path utilities to compute sliced paths, resolve relative paths, and verify path relationships Add pre-execution access validation that raises an error before remote file system command Add user permission validation for: handle, handle_many, download, download_many, mkdir, makedirs, listdir, walk, properties, exists, versions, permalink, copy, move, delete, upload, upload_file --- cterasdk/asynchronous/core/files/browser.py | 50 ++--- cterasdk/asynchronous/core/roles.py | 36 ++++ cterasdk/cio/common.py | 34 +++- cterasdk/cio/core/commands.py | 192 +++++++----------- cterasdk/core/enum.py | 14 +- cterasdk/core/files/browser.py | 72 ++----- cterasdk/core/roles.py | 6 +- cterasdk/core/types.py | 26 +-- cterasdk/exceptions/io/core.py | 6 + cterasdk/lib/session/core.py | 34 +++- cterasdk/objects/asynchronous/core.py | 3 +- .../UserGuides/Miscellaneous/Changelog.rst | 21 +- 12 files changed, 250 insertions(+), 244 deletions(-) create mode 100644 cterasdk/asynchronous/core/roles.py diff --git a/cterasdk/asynchronous/core/files/browser.py b/cterasdk/asynchronous/core/files/browser.py index 4c2e91c9..8230e5f4 100644 --- a/cterasdk/asynchronous/core/files/browser.py +++ b/cterasdk/asynchronous/core/files/browser.py @@ -142,7 +142,7 @@ async def public_link(self, path, access='RO', expire_in=30): """ return await Link(io.public_link, self._core, path, access, expire_in).a_execute() - async def copy(self, *paths, destination=None, resolver=None, cursor=None, wait=False, strict_permission=False): + async def copy(self, *paths, destination=None, resolver=None, cursor=None, wait=False): """ Copy one or more files or folders. @@ -156,16 +156,7 @@ async def copy(self, *paths, destination=None, resolver=None, cursor=None, wait= :raises cterasdk.exceptions.io.core.CopyError: Raised on failure copying resources. """ try: - return await Copy( - io.copy, - self._core, - wait, - *paths, - destination=destination, - resolver=resolver, - cursor=cursor, - strict_permission=strict_permission - ).a_execute() + return await Copy(io.copy, self._core, wait, *paths, destination=destination, resolver=resolver, cursor=cursor).a_execute() except ValueError: raise ValueError('Copy destination was not specified.') @@ -184,7 +175,7 @@ async def permalink(self, path): class CloudDrive(FileBrowser): """Async CloudDrive API with upload and sharing functionality.""" - async def upload(self, destination, handle, name=None, size=None, strict_permission=False): + async def upload(self, destination, handle, name=None, size=None): """ Upload from file handle. @@ -204,10 +195,10 @@ async def upload(self, destination, handle, name=None, size=None, strict_permiss handle, name, size, - strict_permission=strict_permission + ).a_execute() - async def upload_file(self, path, destination, strict_permission=False): + async def upload_file(self, path, destination): """ Upload a file. @@ -224,10 +215,10 @@ async def upload_file(self, path, destination, strict_permission=False): handle, name, commonfs.properties(path)['size'], - strict_permission=strict_permission + ) - async def mkdir(self, path, strict_permission=False): + async def mkdir(self, path): """ Create a directory. @@ -236,9 +227,9 @@ async def mkdir(self, path, strict_permission=False): :rtype: str :raises cterasdk.exceptions.io.core.CreateDirectoryError: Raised on error creating directory. """ - return await CreateDirectory(io.mkdir, self._core, path, strict_permission=strict_permission).a_execute() + return await CreateDirectory(io.mkdir, self._core, path, ).a_execute() - async def makedirs(self, path, strict_permission=False): + async def makedirs(self, path): """ Recursively create a directory. @@ -247,9 +238,9 @@ async def makedirs(self, path, strict_permission=False): :rtype: str :raises cterasdk.exceptions.io.core.CreateDirectoryError: Raised on error creating directory. """ - return await CreateDirectory(io.mkdir, self._core, path, True, strict_permission=strict_permission).a_execute() + return await CreateDirectory(io.mkdir, self._core, path, True, ).a_execute() - async def rename(self, path, name, *, resolver=None, wait=False, strict_permission=False): + async def rename(self, path, name, *, resolver=None, wait=False): """ Rename a file or folder. @@ -268,10 +259,10 @@ async def rename(self, path, name, *, resolver=None, wait=False, strict_permissi path, name, resolver, - strict_permission=strict_permission + ).a_execute() - async def delete(self, *paths, wait=False, strict_permission=False): + async def delete(self, *paths, wait=False): """ Delete one or more files or folders. @@ -281,7 +272,7 @@ async def delete(self, *paths, wait=False, strict_permission=False): :rtype: cterasdk.common.object.Object or :class:`cterasdk.lib.tasks.AwaitablePortalTask` :raises cterasdk.exceptions.io.core.DeleteError: Raised on error deleting resources. """ - return await Delete(io.delete, self._core, wait, *paths, strict_permission=strict_permission).a_execute() + return await Delete(io.delete, self._core, wait, *paths, ).a_execute() async def undelete(self, *paths, wait=False): """ @@ -295,7 +286,7 @@ async def undelete(self, *paths, wait=False): """ return await Recover(io.undelete, self._core, wait, *paths).a_execute() - async def move(self, *paths, destination=None, resolver=None, cursor=None, wait=False, strict_permission=False): + async def move(self, *paths, destination=None, resolver=None, cursor=None, wait=False): """ Move one or more files or folders. @@ -309,16 +300,7 @@ async def move(self, *paths, destination=None, resolver=None, cursor=None, wait= :raises cterasdk.exceptions.io.core.MoveError: Raised on error moving resources. """ try: - return await Move( - io.move, - self._core, - wait, - *paths, - destination=destination, - resolver=resolver, - cursor=cursor, - strict_permission=strict_permission - ).a_execute() + return await Move(io.move, self._core, wait, *paths, destination=destination, resolver=resolver, cursor=cursor).a_execute() except ValueError: raise ValueError('Move destination was not specified.') diff --git a/cterasdk/asynchronous/core/roles.py b/cterasdk/asynchronous/core/roles.py new file mode 100644 index 00000000..6e762ce7 --- /dev/null +++ b/cterasdk/asynchronous/core/roles.py @@ -0,0 +1,36 @@ +import logging + +from ...core.enum import RoleResolver +from ...core.types import RoleSettings +from .base_command import BaseCommand + + +logger = logging.getLogger('cterasdk.core') + + +class Roles(BaseCommand): + """ + Role Settings APIs + """ + + @staticmethod + def find(role): + """ + Find Role + """ + options = {k: v for k, v in RoleResolver.__dict__.items() if not k.startswith('_')} + return options.get(role, None) + + async def get(self, role): + """ + Get Role + + :param str role: Role + :returns: Role settings + :rtype: cterasdk.core.types.RoleSettings + """ + name = Roles.find(role) + if name: + return RoleSettings.from_server_object(await self._core.v1.api.get(f'/rolesSettings/{name}')) + logger.warning('Could not find role. %s', {'role': role}) + return None diff --git a/cterasdk/cio/common.py b/cterasdk/cio/common.py index bc9af643..199b7cc9 100644 --- a/cterasdk/cio/common.py +++ b/cterasdk/cio/common.py @@ -1,10 +1,26 @@ import urllib.parse +from functools import wraps from pathlib import PurePosixPath from ..common import Object from ..common.utils import utf8_decode from ..convert.serializers import toxmlstr +def resolver(func): + @wraps(func) + def wrapper(self, p): + if isinstance(p, BasePath): + p = p.reference + elif isinstance(p, str): + pass + else: + raise ValueError( + f"Parameter 'p' must be str or BasePath, got {type(p).__name__}" + ) + return func(self, p) + return wrapper + + class BasePath: """Base Path for CTERA Portal and CTERA Edge""" @@ -20,7 +36,7 @@ def __init__(self, scope, reference): if isinstance(reference, str): reference = reference.lstrip('/') self._scope = PurePosixPath(scope) - self._reference = PurePosixPath(reference) + self._reference = PurePosixPath(*reference) if isinstance(reference, tuple) else PurePosixPath(reference) @property def scope(self): @@ -58,6 +74,14 @@ def absolute_encode(self): def absolute_parent(self): return self.parent.as_posix() + @resolver + def is_relative_to(self, p): + return self.reference.is_relative_to(p) + + @resolver + def relative_to(self, p): + return self.__class__(self.reference.relative_to(p).as_posix()) # pylint: disable=no-value-for-parameter + @property def extension(self): return self.reference.suffix @@ -74,6 +98,14 @@ def join(self, p): def parts(self): return self.reference.parts + def __getitem__(self, key): + if isinstance(key, slice): + return self.__class__(self.parts[key]) + elif isinstance(key, int): + return self.parts[key] + else: + raise TypeError("Invalid argument type") + def __eq__(self, p): return self.absolute == p.absolute diff --git a/cterasdk/cio/core/commands.py b/cterasdk/cio/core/commands.py index 4bc0ee78..77122fd6 100644 --- a/cterasdk/cio/core/commands.py +++ b/cterasdk/cio/core/commands.py @@ -7,7 +7,7 @@ from contextlib import contextmanager from ...common import Object, DateTimeUtils from ...core.enum import ProtectionLevel, CollaboratorType, SearchType, PortalAccountType, FileAccessMode, \ - UploadError, ResourceScope, ResourceError + UploadError, ResourceScope, ResourceError, Context, Role, Administrators from ...core.types import PortalAccount, UserAccount, GroupAccount, Collaborator from ... import exceptions from ...lib.storage import synfs, asynfs, commonfs @@ -20,74 +20,49 @@ logger = logging.getLogger('cterasdk.core') -def _normalize_rc(rc): - if isinstance(rc, str): - return rc.strip() - return rc +def administrator_namespace(path): + return path[0] in ['.', 'backupFolders', 'backups', 'Users'] if len(path.parts) > 0 else True -def _normalize_msg(msg): - if isinstance(msg, str): - return msg.strip().lower() - return msg - - -def _extract_rc_msg(result): - if result is None: - return None, None - if isinstance(result, str): - return None, result - return getattr(result, 'rc', None), getattr(result, 'msg', None) - - -_STRICT_PERMISSION_DENIED_MESSAGES = { - 'permission denied', - 'access denied', - 'read only', - 'action is not allowed', -} - -# Some APIs return condensed "permissiondenied" in rc/msg/error_type. -_STRICT_PERMISSION_DENIED_TOKENS = {'permissiondenied'} -_STRICT_PERMISSION_AMBIGUOUS_RC = {None, 0, '0'} -_STRICT_PERMISSION_EMPTY_MESSAGES = {None, ''} - - -def _raise_strict_permission_denied(result, path): - rc, msg = _extract_normalized_rc_msg(result) - logger.info( - 'strict_permission response for %s: rc=%r msg=%r raw=%s', - path, rc, msg, type(result).__name__ - ) - if _is_strict_permission_denied_response(rc, msg): - raise exceptions.io.core.PrivilegeError(path) - - -def _extract_normalized_rc_msg(result): - rc, msg = _extract_rc_msg(result) - return _normalize_rc(rc), _normalize_msg(msg) - - -def _extract_task_error_fields(result): - rc = _normalize_rc(getattr(result, 'rc', None)) - msg = _normalize_msg(getattr(result, 'msg', None)) - error_type = _normalize_msg(getattr(result, 'error_type', None)) - return rc, msg, error_type - - -def _is_strict_permission_denied_response(rc, msg): - if msg in _STRICT_PERMISSION_DENIED_MESSAGES: +def is_password_protected(path): + """ + Determine whether a path is password protected + :returns: ``True`` if password protected, ``False`` otherwise. + :rtype: bool + """ + if len(path.parts) > 1 and path[0] in ['backups', 'backupFolders']: return True - if rc in _STRICT_PERMISSION_DENIED_TOKENS or msg in _STRICT_PERMISSION_DENIED_TOKENS: + if len(path.parts) > 2 and path[0] == 'Users': return True - return rc in _STRICT_PERMISSION_AMBIGUOUS_RC and msg in _STRICT_PERMISSION_EMPTY_MESSAGES + return False -def _is_strict_permission_denied_task(rc, msg, error_type): - _ = rc - if error_type in _STRICT_PERMISSION_DENIED_MESSAGES or error_type in _STRICT_PERMISSION_DENIED_TOKENS: - return True - return msg in _STRICT_PERMISSION_DENIED_MESSAGES +def ensure_user_access(user_session, path): + """ + Determine whether the current user has permission to access the specified path. + + :param cterasdk.lib.session.Session user_session: + The active user session used to evaluate permissions. + :param str or cterasdk.cio.core.types.PortalPath path: + The target path to validate. + """ + relative = path.relative + if user_session.account.role.name in Administrators: + if user_session.context == Context.admin: + if not administrator_namespace(path): + return False, exceptions.io.core.ContextError(relative) + if not user_session.account.role.access_end_user_folders and is_password_protected(path): + return False, exceptions.io.core.PrivilegeError(relative) + elif user_session.context == Context.ServicesPortal: + if not user_session.account.role.access_end_user_folders and is_password_protected(path): + return False, exceptions.io.core.PrivilegeError(relative) + return True, None + + +def raise_or_suppress_access_error(receiver, path): + allowed_access, error = ensure_user_access(receiver.session(), path) + if not allowed_access: + raise error def split_file_directory(listdir, receiver, destination): @@ -190,13 +165,12 @@ def ensure_writeable(resource, directory): class Upload(PortalCommand): - def __init__(self, function, receiver, listdir, destination, fd, name, size, strict_permission=False): + def __init__(self, function, receiver, listdir, destination, fd, name, size): super().__init__(function, receiver) self.destination = automatic_resolution(destination, receiver.context) self._resolver = PathResolver(listdir, receiver, self.destination, name) self.size = size self.fd = fd - self._strict_permission = strict_permission self._resource = None def get_parameter(self): @@ -211,6 +185,7 @@ def get_parameter(self): return param def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.destination) destination_prerequisite_conditions(self.destination) ensure_writeable(self._resource, self.destination.parent) logger.info('Uploading: %s', self.destination) @@ -227,8 +202,6 @@ async def _a_execute(self): def _handle_response(self, r): path = self.destination.relative - if self._strict_permission: - _raise_strict_permission_denied(r, path) if r.rc: error = exceptions.io.core.UploadError(r.msg, path) logger.error('Upload failed: %s', path) @@ -390,6 +363,7 @@ def get_parameter(self): return self.path.reference def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.path) logger.info('Getting handle: %s', self.path) def _execute(self): @@ -443,6 +417,7 @@ def __init__(self, function, receiver, resource, directory, *objects): self.objects = objects def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.directory) logger.info('Getting handle: %s', [self.directory.join(o).relative for o in self.objects]) def get_parameter(self): @@ -515,6 +490,7 @@ def get_parameter(self): return builder.build() def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.path) logger.info('Listing directory: %s', self.path) def _execute(self): @@ -616,7 +592,7 @@ def generate(self): print('Enumerating: ', path or '.') for o in ResourceIterator(self._function, self._receiver, path, None, self.include_deleted, None, None).execute(): yield self._process_object(o) - except exceptions.io.core.ListDirectoryError as e: + except (exceptions.io.core.ListDirectoryError, exceptions.io.core.PrivilegeError) as e: RecursiveIterator._suppress_error(e) async def a_generate(self): @@ -624,7 +600,7 @@ async def a_generate(self): try: async for o in ResourceIterator(self._function, self._receiver, path, None, self.include_deleted, None, None).a_execute(): yield self._process_object(o) - except exceptions.io.core.ListDirectoryError as e: + except (exceptions.io.core.ListDirectoryError, exceptions.io.core.PrivilegeError) as e: RecursiveIterator._suppress_error(e) def _process_object(self, o): @@ -634,9 +610,12 @@ def _process_object(self, o): @staticmethod def _suppress_error(e): - if not isinstance(e.__cause__, exceptions.io.core.FolderNotFoundError): + if isinstance(e, exceptions.io.core.PrivilegeError): + logger.warning("Could not list directory contents: %s. Access denied.", e.path) + elif isinstance(e.__cause__, exceptions.io.core.FolderNotFoundError): + logger.warning("Could not list directory contents: %s. No such directory.", e.path) + else: raise e - logger.warning("Could not list directory contents: %s. No such directory.", e.path) class ListVersions(PortalCommand): @@ -646,6 +625,7 @@ def __init__(self, function, receiver, path): self.path = automatic_resolution(path, receiver.context) def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.path) logger.info('Listing versions: %s', self.path) def get_parameter(self): @@ -669,11 +649,10 @@ def _handle_exception(self, e): class CreateDirectory(PortalCommand): """Create Directory""" - def __init__(self, function, receiver, path, parents=False, strict_permission=False): + def __init__(self, function, receiver, path, parents=False): super().__init__(function, receiver) self.path = automatic_resolution(path, receiver.context) self.parents = parents - self._strict_permission = strict_permission def get_parameter(self): param = Object() @@ -682,6 +661,7 @@ def get_parameter(self): return param def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.path) logger.info('Creating directory: %s', self.path) def _parents_generator(self): @@ -696,11 +676,7 @@ def _execute(self): if self.parents: for path in self._parents_generator(): try: - CreateDirectory( - self._function, - self._receiver, - path - ).execute() + CreateDirectory(self._function, self._receiver, path).execute() except exceptions.io.core.CreateDirectoryError as e: CreateDirectory._suppress_file_conflict_error(e) with self.trace_execution(): @@ -710,11 +686,7 @@ async def _a_execute(self): if self.parents: for path in self._parents_generator(): try: - await CreateDirectory( - self._function, - self._receiver, - path - ).a_execute() + await CreateDirectory(self._function, self._receiver, path).a_execute() except exceptions.io.core.CreateDirectoryError as e: CreateDirectory._suppress_file_conflict_error(e) with self.trace_execution(): @@ -729,8 +701,6 @@ def _handle_response(self, r): path = self.path.relative if r is None or r == 'Ok': return path - if self._strict_permission: - _raise_strict_permission_denied(r, path) error, cause = exceptions.io.core.CreateDirectoryError(path), None if r == ResourceError.FileWithTheSameNameExist: @@ -987,11 +957,10 @@ def _handle_response(self, r): class TaskCommand(PortalCommand): - def __init__(self, function, receiver, block, strict_permission=False): + def __init__(self, function, receiver, block): super().__init__(function, receiver) self.block = block self.background = True - self._strict_permission = strict_permission @abstractmethod def _progress_str(self): @@ -1023,10 +992,6 @@ async def _a_execute(self): return await function(self.get_parameter()) def _handle_response(self, r): - if self._strict_permission: - rc, msg, error_type = _extract_task_error_fields(r) - if _is_strict_permission_denied_task(rc, msg, error_type): - raise exceptions.io.core.PrivilegeError('') if not self.block: return r @@ -1047,8 +1012,8 @@ def _task_error(self, task): # pylint: disable=no-self-use class MultiResourceCommand(TaskCommand): - def __init__(self, function, receiver, block, *paths, strict_permission=False): - super().__init__(function, receiver, block, strict_permission=strict_permission) + def __init__(self, function, receiver, block, *paths): + super().__init__(function, receiver, block) self.paths = list(automatic_resolution(paths, receiver.context)) def get_parameter(self): @@ -1059,6 +1024,7 @@ def get_parameter(self): def _before_command(self): for path in self.paths: + raise_or_suppress_access_error(self._receiver, path) logger.info('%s: %s', self._progress_str(), path) def _task_complete(self, task): @@ -1087,9 +1053,8 @@ def _task_error(self, task): class ResolverCommand(TaskCommand): - def __init__(self, function, receiver, block, *paths, destination=None, resolver=None, cursor=None, - strict_permission=False): - super().__init__(function, receiver, block, strict_permission=strict_permission) + def __init__(self, function, receiver, block, *paths, destination=None, resolver=None, cursor=None): + super().__init__(function, receiver, block) self.paths = list(automatic_resolution(paths, receiver.context)) self.destination = automatic_resolution(destination, receiver.context) self.resolver = resolver @@ -1123,6 +1088,8 @@ def _before_command(self): src, dest = path, self.destination if isinstance(path, tuple): src, dest = path + raise_or_suppress_access_error(self._receiver, src) + raise_or_suppress_access_error(self._receiver, dest) logger.info('%s: %s to: %s', self._progress_str(), src.relative, dest.relative) @abstractmethod @@ -1178,13 +1145,11 @@ def _progress_str(self): def _try_with_resolver(self, cursor): return Copy(self._function, self._receiver, self.block, *self.paths, - destination=self.destination, resolver=self.resolver, cursor=cursor, - strict_permission=self._strict_permission).execute() + destination=self.destination, resolver=self.resolver, cursor=cursor).execute() async def _a_try_with_resolver(self, cursor): return await Copy(self._function, self._receiver, self.block, *self.paths, - destination=self.destination, resolver=self.resolver, cursor=cursor, - strict_permission=self._strict_permission).a_execute() + destination=self.destination, resolver=self.resolver, cursor=cursor).a_execute() @property def _error_object(self): @@ -1198,13 +1163,11 @@ def _progress_str(self): def _try_with_resolver(self, cursor): return Move(self._function, self._receiver, self.block, *self.paths, - destination=self.destination, resolver=self.resolver, cursor=cursor, - strict_permission=self._strict_permission).execute() + destination=self.destination, resolver=self.resolver, cursor=cursor).execute() async def _a_try_with_resolver(self, cursor): return await Move(self._function, self._receiver, self.block, *self.paths, - destination=self.destination, resolver=self.resolver, cursor=cursor, - strict_permission=self._strict_permission).a_execute() + destination=self.destination, resolver=self.resolver, cursor=cursor).a_execute() @property def _error_object(self): @@ -1216,26 +1179,23 @@ class Rename(Move): def _progress_str(self): return 'Renaming' - def __init__(self, function, receiver, block, path, new_name, resolver, cursor=None, strict_permission=False): - super().__init__( - function, - receiver, - block, - *[(path, automatic_resolution(path, receiver.context).parent.join(new_name))], - resolver=resolver, - cursor=cursor, - strict_permission=strict_permission - ) + def __init__(self, function, receiver, block, path, new_name, resolver, cursor=None): + super().__init__(function, receiver, block, *[(path, automatic_resolution(path, receiver.context).parent.join(new_name))], + resolver=resolver, cursor=cursor) + + def _before_command(self): + raise_or_suppress_access_error(self._receiver, self.paths[0]) + return super()._before_command() def _try_with_resolver(self, cursor): source, destination = self.paths[0] return Rename(self._function, self._receiver, self.block, source, destination.name, - resolver=self.resolver, cursor=cursor, strict_permission=self._strict_permission).execute() + resolver=self.resolver, cursor=cursor).execute() async def _a_try_with_resolver(self, cursor): source, destination = self.paths[0] return await Rename(self._function, self._receiver, self.block, source, destination.name, - resolver=self.resolver, cursor=cursor, strict_permission=self._strict_permission).a_execute() + resolver=self.resolver, cursor=cursor).a_execute() @property def _error_object(self): diff --git a/cterasdk/core/enum.py b/cterasdk/core/enum.py index 6de3d955..45041b65 100644 --- a/cterasdk/core/enum.py +++ b/cterasdk/core/enum.py @@ -68,19 +68,23 @@ class Role: """ Portal User Role - :ivar str Disabled: Disabled user role - :ivar str EndUser: EndUser user role - :ivar str ReadWriteAdmin: ReadWriteAdmin user role - :ivar str ReadOnlyAdmin: ReadOnlyAdmin user role - :ivar str Support: Support user role + :ivar str Disabled: Disabled + :ivar str EndUser: End User + :ivar str ReadWriteAdmin: Read Write Administrator + :ivar str ReadOnlyAdmin: Read Only Administrator + :ivar str Support: Support Administrator """ Disabled = "Disabled" EndUser = "EndUser" ReadWriteAdmin = "ReadWriteAdmin" ReadOnlyAdmin = "ReadOnlyAdmin" + ComplianceOfficer = "ComplianceOffer" Support = "Support" +Administrators = [Role.ReadWriteAdmin, Role.ReadOnlyAdmin, Role.ComplianceOfficer, Role.Support] + + class RoleResolver: """ Role Settings Resolver diff --git a/cterasdk/core/files/browser.py b/cterasdk/core/files/browser.py index f6c15d39..46001fea 100644 --- a/cterasdk/core/files/browser.py +++ b/cterasdk/core/files/browser.py @@ -142,7 +142,7 @@ def public_link(self, path, access='RO', expire_in=30): """ return Link(io.public_link, self._core, path, access, expire_in).execute() - def copy(self, *paths, destination=None, resolver=None, cursor=None, wait=True, strict_permission=False): + def copy(self, *paths, destination=None, resolver=None, cursor=None, wait=True): """ Copy one or more files or folders. @@ -156,16 +156,7 @@ def copy(self, *paths, destination=None, resolver=None, cursor=None, wait=True, :raises cterasdk.exceptions.io.core.CopyError: Raised on failure to copy resources. """ try: - return Copy( - io.copy, - self._core, - wait, - *paths, - destination=destination, - resolver=resolver, - cursor=cursor, - strict_permission=strict_permission - ).execute() + return Copy(io.copy, self._core, wait, *paths, destination=destination, resolver=resolver, cursor=cursor).execute() except ValueError: raise ValueError('Copy destination was not specified.') @@ -184,7 +175,7 @@ def permalink(self, path): class CloudDrive(FileBrowser): """CloudDrive extends FileBrowser with upload and share functionality.""" - def upload(self, destination, handle, name=None, size=None, strict_permission=False): + def upload(self, destination, handle, name=None, size=None): """ Upload from file handle. @@ -196,18 +187,10 @@ def upload(self, destination, handle, name=None, size=None, strict_permission=Fa :rtype: str :raises cterasdk.exceptions.io.core.UploadError: Raised on upload failure. """ - return Upload( - io.upload, - self._core, - io.listdir, - destination, - handle, - name, - size, - strict_permission=strict_permission + return Upload(io.upload, self._core, io.listdir, destination, handle, name, size ).execute() - def upload_file(self, path, destination, strict_permission=False): + def upload_file(self, path, destination): """ Upload a file. @@ -219,15 +202,9 @@ def upload_file(self, path, destination, strict_permission=False): """ _, name = commonfs.split_file_directory(path) with open(path, 'rb') as handle: - return self.upload( - destination, - handle, - name, - commonfs.properties(path)['size'], - strict_permission=strict_permission - ) + return self.upload(destination, handle, name, commonfs.properties(path)['size']) - def mkdir(self, path, strict_permission=False): + def mkdir(self, path): """ Create a directory. @@ -236,9 +213,9 @@ def mkdir(self, path, strict_permission=False): :rtype: str :raises cterasdk.exceptions.io.core.CreateDirectoryError: Raised on error creating directory. """ - return CreateDirectory(io.mkdir, self._core, path, strict_permission=strict_permission).execute() + return CreateDirectory(io.mkdir, self._core, path).execute() - def makedirs(self, path, strict_permission=False): + def makedirs(self, path): """ Recursively create a directory. @@ -247,9 +224,9 @@ def makedirs(self, path, strict_permission=False): :rtype: str :raises cterasdk.exceptions.io.core.CreateDirectoryError: Raised on error creating directory. """ - return CreateDirectory(io.mkdir, self._core, path, True, strict_permission=strict_permission).execute() + return CreateDirectory(io.mkdir, self._core, path, True).execute() - def rename(self, path, name, *, resolver=None, wait=True, strict_permission=False): + def rename(self, path, name, *, resolver=None, wait=True): """ Rename a file or folder. @@ -261,17 +238,9 @@ def rename(self, path, name, *, resolver=None, wait=True, strict_permission=Fals :rtype: cterasdk.common.object.Object or :class:`cterasdk.lib.tasks.AwaitablePortalTask` :raises cterasdk.exceptions.io.core.RenameError: Raised on error renaming object. """ - return Rename( - io.move, - self._core, - wait, - path, - name, - resolver, - strict_permission=strict_permission - ).execute() + return Rename(io.move, self._core, wait, path, name, resolver).execute() - def delete(self, *paths, wait=True, strict_permission=False): + def delete(self, *paths, wait=True): """ Delete one or more files or folders. @@ -281,7 +250,7 @@ def delete(self, *paths, wait=True, strict_permission=False): :rtype: cterasdk.common.object.Object or :class:`cterasdk.lib.tasks.AwaitablePortalTask` :raises cterasdk.exceptions.io.core.DeleteError: Raised on error deleting resources. """ - return Delete(io.delete, self._core, wait, *paths, strict_permission=strict_permission).execute() + return Delete(io.delete, self._core, wait, *paths).execute() def undelete(self, *paths, wait=True): """ @@ -295,7 +264,7 @@ def undelete(self, *paths, wait=True): """ return Recover(io.undelete, self._core, wait, *paths).execute() - def move(self, *paths, destination=None, resolver=None, cursor=None, wait=True, strict_permission=False): + def move(self, *paths, destination=None, resolver=None, cursor=None, wait=True): """ Move one or more files or folders. @@ -309,16 +278,7 @@ def move(self, *paths, destination=None, resolver=None, cursor=None, wait=True, :raises cterasdk.exceptions.io.core.MoveError: Raised on error moving resources. """ try: - return Move( - io.move, - self._core, - wait, - *paths, - destination=destination, - resolver=resolver, - cursor=cursor, - strict_permission=strict_permission - ).execute() + return Move(io.move, self._core, wait, *paths, destination=destination, resolver=resolver, cursor=cursor).execute() except ValueError: raise ValueError('Move destination was not specified.') diff --git a/cterasdk/core/roles.py b/cterasdk/core/roles.py index ee50fa96..119e8ab2 100644 --- a/cterasdk/core/roles.py +++ b/cterasdk/core/roles.py @@ -36,9 +36,9 @@ def get(self, role): :returns: Role settings :rtype: cterasdk.core.types.RoleSettings """ - role = Roles.find(role) - if role: - return RoleSettings.from_server_object(self._core.api.get(f'/rolesSettings/{role}')) + name = Roles.find(role) + if name: + return RoleSettings.from_server_object(self._core.api.get(f'/rolesSettings/{name}')) logger.warning('Could not find role. %s', {'role': role}) return None diff --git a/cterasdk/core/types.py b/cterasdk/core/types.py index 9687359c..5e792986 100644 --- a/cterasdk/core/types.py +++ b/cterasdk/core/types.py @@ -778,14 +778,14 @@ class RoleSettings(Object): # pylint: disable=too-many-instance-attributes :ivar bool manage_cloud_drives: Manage Cloud Folders :ivar bool manage_plans: Manage Plans :ivar bool manage_logs: Manage Log Settings - :ivar bool allow_folders_files_permanent_delete: Allow Folders Files Permanent Delete - :ivar bool can_manage_legal_holds: Manage Legal Holds - :ivar bool can_manage_compliance_settings: Manage Compliance Settings + :ivar bool permanent_delete: Allow Permanent Delete + :ivar bool manage_legal_holds: Manage Legal Holds + :ivar bool manage_compliance_settings: Manage Compliance Settings """ # pylint: disable=too-many-arguments, too-many-locals def __init__(self, name, sudo, enable_remote_wipe, enable_sso, enable_seeding_export, enable_seeding_import, access_end_user_folders, update_settings, update_roles, update_account_emails, update_account_password, manage_cloud_drives, manage_plans, - manage_users, manage_logs, allow_folders_files_permanent_delete, can_manage_legal_holds, can_manage_compliance_settings): + manage_users, manage_logs, permanent_delete, manage_legal_holds, manage_compliance_settings): super().__init__() self.name = name self.sudo = sudo @@ -802,9 +802,9 @@ def __init__(self, name, sudo, enable_remote_wipe, enable_sso, enable_seeding_ex self.manage_plans = manage_plans self.manage_users = manage_users self.manage_logs = manage_logs - self.allow_folders_files_permanent_delete = allow_folders_files_permanent_delete - self.can_manage_legal_holds = can_manage_legal_holds - self.can_manage_compliance_settings = can_manage_compliance_settings + self.permanent_delete = permanent_delete + self.manage_legal_holds = manage_legal_holds + self.manage_compliance_settings = manage_compliance_settings def to_server_object(self): param = Object() @@ -824,9 +824,9 @@ def to_server_object(self): param.canManagePlans = self.manage_plans param.canManageUsers = self.manage_users param.canManageLogSettings = self.manage_logs - param.allowFoldersFilesPermanentDelete = self.allow_folders_files_permanent_delete - param.canManageLegalHolds = self.can_manage_legal_holds - param.canManageComplianceSetting = self.can_manage_compliance_settings + param.allowFoldersFilesPermanentDelete = self.permanent_delete + param.canManageLegalHolds = self.manage_legal_holds + param.canManageComplianceSetting = self.manage_compliance_settings return param @staticmethod @@ -847,9 +847,9 @@ def from_server_object(server_object): 'manage_plans': server_object.canManagePlans, 'manage_users': server_object.canManageUsers, 'manage_logs': server_object.canManageLogSettings, - 'allow_folders_files_permanent_delete': server_object.allowFoldersFilesPermanentDelete, - 'can_manage_legal_holds': server_object.canManageLegalHolds, - 'can_manage_compliance_settings': server_object.canManageComplianceSetting + 'permanent_delete': server_object.allowFoldersFilesPermanentDelete, + 'manage_legal_holds': server_object.canManageLegalHolds, + 'manage_compliance_settings': server_object.canManageComplianceSetting } return RoleSettings(**params) diff --git a/cterasdk/exceptions/io/core.py b/cterasdk/exceptions/io/core.py index f900dfe9..a80df8e1 100644 --- a/cterasdk/exceptions/io/core.py +++ b/cterasdk/exceptions/io/core.py @@ -56,6 +56,12 @@ def __init__(self, filename): super().__init__(errno.EACCES, 'Access denied. No permission to access resource', filename) +class ContextError(PathError): + + def __init__(self, filename): + super().__init__(EREMOTEIO, "Global administrator access is restricted to: 'backupFolders', 'backups', and 'Users'.", filename) + + class NTACLError(PathError): def __init__(self, filename): diff --git a/cterasdk/lib/session/core.py b/cterasdk/lib/session/core.py index 49874110..300cc53c 100644 --- a/cterasdk/lib/session/core.py +++ b/cterasdk/lib/session/core.py @@ -1,15 +1,26 @@ import logging from .base import BaseSession, BaseUser from .types import Product +from ...common import Object +from ...core.enum import Administrators class PortalUser(BaseUser): """Local User""" - def __init__(self, name, domain, tenant, role): + def __init__(self, name, domain, tenant, role, authorizations): super().__init__(name, domain) self.tenant = tenant - self.role = role + self.role = Role(role, authorizations) + + +class Role(Object): + """User Role""" + + def __init__(self, name, authorizations): + self.name = name + if authorizations: + self.access_end_user_folders = authorizations.access_end_user_folders class Session(BaseSession): @@ -22,25 +33,28 @@ def __init__(self, address, context): def _start_session(self, session): logging.getLogger('cterasdk.core').debug('Starting Session.') - current_session = session.api.get('/currentSession') + user_session = session.api.get('/currentSession') current_tenant = session.api.get('/currentPortal') or Session.Administration software_version = session.api.get('/version') - self._update_session(current_session, current_tenant, software_version) + authorizations = session.roles.get(user_session.role) if user_session.role in Administrators else None + self._update_session(user_session, current_tenant, software_version, authorizations) async def _async_start_session(self, session): logging.getLogger('cterasdk.core').debug('Starting Session.') - current_session = session.v1.api.get('/currentSession') + user_session = await session.v1.api.get('/currentSession') current_tenant = session.v1.api.get('/currentPortal') software_version = session.v1.api.get('/version') - self._update_session(await current_session, await current_tenant or Session.Administration, await software_version) + authorizations = await session.roles.get(user_session.role) if user_session.role in Administrators else None + self._update_session(user_session, await current_tenant or Session.Administration, await software_version, authorizations) - def _update_session(self, current_session, current_tenant, software_version): + def _update_session(self, user_session, current_tenant, software_version, authorizations): self._update_account( PortalUser( - current_session.username, - current_session.domain, + user_session.username, + user_session.domain, current_tenant, - current_session.role + user_session.role, + authorizations ) ) self._update_software_version(software_version) diff --git a/cterasdk/objects/asynchronous/core.py b/cterasdk/objects/asynchronous/core.py index 42e1ead9..723d7d96 100644 --- a/cterasdk/objects/asynchronous/core.py +++ b/cterasdk/objects/asynchronous/core.py @@ -4,7 +4,7 @@ from ...clients import clients from .. import authenticators from ...lib.session.core import Session -from ...asynchronous.core import files, login, cloudfs, notifications, portals, settings, tasks, users +from ...asynchronous.core import files, login, cloudfs, notifications, portals, roles, settings, tasks, users class Clients: @@ -61,6 +61,7 @@ def __init__(self, host, port=None, https=True): self.cloudfs = cloudfs.CloudFS(self) self.files = files.CloudDrive(self) self.notifications = notifications.Notifications(self) + self.roles = roles.Roles(self) self.settings = settings.Settings(self) self.tasks = tasks.Tasks(self) self.users = users.Users(self) diff --git a/docs/source/UserGuides/Miscellaneous/Changelog.rst b/docs/source/UserGuides/Miscellaneous/Changelog.rst index 035fe709..aee3127c 100644 --- a/docs/source/UserGuides/Miscellaneous/Changelog.rst +++ b/docs/source/UserGuides/Miscellaneous/Changelog.rst @@ -6,11 +6,22 @@ Changelog Improvements ^^^^^^^^^^^^ -* Added ``strict_permission`` support to core file operations (mkdir/makedirs/upload/copy/move/rename/delete) -* Refined strict permission handling to avoid false permission errors on successful operations -* Improved strict permission detection for background tasks using rc/msg/error_type tuples -* Adjusted recursive directory creation to skip portal root segments -* Treat completed-with-warnings background tasks as successful results + +* Capture the logged-on user's permission to access user folders +* Add ability to: + + - Compute paths based on slices + - Compute paths relative to another path + - Verify whether a path is relative to another path + +* Add a function to verify and raise an error on user access before executing a remote file system command +* Support validating user permissions for the following commands: + + ``handle``, ``handle_many``, ``download``, ``download_many``, ``mkdir``, + ``makedirs``, ``listdir``, ``walk``, ``properties``, ``exists``, + ``versions``, ``permalink``, ``copy``, ``move``, ``delete``, + ``upload``, ``upload_file`` + * Support "upload only" permission when sharing public links * Migrate SDK configuration from YAML file to Python module * Support bearer token authentication From df8710e15fc9a72bbd195ceb9de437f44067eca5 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Wed, 18 Feb 2026 18:17:17 -0500 Subject: [PATCH 4/8] update to pass flake8 --- cterasdk/asynchronous/core/files/browser.py | 9 +++------ cterasdk/cio/common.py | 7 +++---- cterasdk/cio/core/commands.py | 2 +- cterasdk/core/files/browser.py | 3 +-- cterasdk/lib/session/core.py | 1 + 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/cterasdk/asynchronous/core/files/browser.py b/cterasdk/asynchronous/core/files/browser.py index 8230e5f4..6f09208c 100644 --- a/cterasdk/asynchronous/core/files/browser.py +++ b/cterasdk/asynchronous/core/files/browser.py @@ -194,8 +194,7 @@ async def upload(self, destination, handle, name=None, size=None): destination, handle, name, - size, - + size ).a_execute() async def upload_file(self, path, destination): @@ -214,8 +213,7 @@ async def upload_file(self, path, destination): destination, handle, name, - commonfs.properties(path)['size'], - + commonfs.properties(path)['size'] ) async def mkdir(self, path): @@ -258,8 +256,7 @@ async def rename(self, path, name, *, resolver=None, wait=False): wait, path, name, - resolver, - + resolver ).a_execute() async def delete(self, *paths, wait=False): diff --git a/cterasdk/cio/common.py b/cterasdk/cio/common.py index 199b7cc9..07c59a7d 100644 --- a/cterasdk/cio/common.py +++ b/cterasdk/cio/common.py @@ -100,11 +100,10 @@ def parts(self): def __getitem__(self, key): if isinstance(key, slice): - return self.__class__(self.parts[key]) - elif isinstance(key, int): + return self.__class__(self.parts[key]) # pylint: disable=no-value-for-parameter + if isinstance(key, int): return self.parts[key] - else: - raise TypeError("Invalid argument type") + raise TypeError("Invalid argument type") def __eq__(self, p): return self.absolute == p.absolute diff --git a/cterasdk/cio/core/commands.py b/cterasdk/cio/core/commands.py index 77122fd6..73a1c3f9 100644 --- a/cterasdk/cio/core/commands.py +++ b/cterasdk/cio/core/commands.py @@ -7,7 +7,7 @@ from contextlib import contextmanager from ...common import Object, DateTimeUtils from ...core.enum import ProtectionLevel, CollaboratorType, SearchType, PortalAccountType, FileAccessMode, \ - UploadError, ResourceScope, ResourceError, Context, Role, Administrators + UploadError, ResourceScope, ResourceError, Context, Administrators from ...core.types import PortalAccount, UserAccount, GroupAccount, Collaborator from ... import exceptions from ...lib.storage import synfs, asynfs, commonfs diff --git a/cterasdk/core/files/browser.py b/cterasdk/core/files/browser.py index 46001fea..2202ecc5 100644 --- a/cterasdk/core/files/browser.py +++ b/cterasdk/core/files/browser.py @@ -187,8 +187,7 @@ def upload(self, destination, handle, name=None, size=None): :rtype: str :raises cterasdk.exceptions.io.core.UploadError: Raised on upload failure. """ - return Upload(io.upload, self._core, io.listdir, destination, handle, name, size - ).execute() + return Upload(io.upload, self._core, io.listdir, destination, handle, name, size).execute() def upload_file(self, path, destination): """ diff --git a/cterasdk/lib/session/core.py b/cterasdk/lib/session/core.py index 300cc53c..efbb73c4 100644 --- a/cterasdk/lib/session/core.py +++ b/cterasdk/lib/session/core.py @@ -18,6 +18,7 @@ class Role(Object): """User Role""" def __init__(self, name, authorizations): + super().__init__() self.name = name if authorizations: self.access_end_user_folders = authorizations.access_end_user_folders From c722b9bd6e47f1d969a7e3c94c67a3cd8a0d471a Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Wed, 18 Feb 2026 18:19:54 -0500 Subject: [PATCH 5/8] remove strict_permission from ut --- tests/ut/core/user/test_mkdir.py | 24 ------------------------ tests/ut/core/user/test_move.py | 18 +----------------- 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/tests/ut/core/user/test_mkdir.py b/tests/ut/core/user/test_mkdir.py index 901f6aca..f88d4c99 100644 --- a/tests/ut/core/user/test_mkdir.py +++ b/tests/ut/core/user/test_mkdir.py @@ -24,27 +24,3 @@ def test_mkdir(self): }) actual_param = self._services.api.execute.call_args[0][2] self._assert_equal_objects(actual_param, expected_param) - - def test_mkdir_strict_permission_success(self): - self._init_services(execute_response=None) - ret = self._services.files.mkdir(self._directory, strict_permission=True) - self.assertEqual(ret, self._directory) - - def test_mkdir_strict_permission_denied(self): - execute_response = Object(rc=0) - self._init_services(execute_response=execute_response) - with self.assertRaises(exceptions.io.core.PrivilegeError): - self._services.files.mkdir(self._directory, strict_permission=True) - - def test_mkdir_strict_permission_denied_message(self): - execute_response = Object(msg='access denied') - self._init_services(execute_response=execute_response) - with self.assertRaises(exceptions.io.core.PrivilegeError): - self._services.files.mkdir(self._directory, strict_permission=True) - - def test_makedirs_strict_permission_root_path(self): - rooted_directories = 'Team Portal/Engineering/Documents' - self._init_services(execute_response=None) - ret = self._services.files.makedirs(rooted_directories, strict_permission=True) - self.assertEqual(self._services.api.execute.call_count, len(rooted_directories.split('/'))) - self.assertEqual(ret, rooted_directories) diff --git a/tests/ut/core/user/test_move.py b/tests/ut/core/user/test_move.py index 6e5e2985..a66144fa 100644 --- a/tests/ut/core/user/test_move.py +++ b/tests/ut/core/user/test_move.py @@ -24,22 +24,6 @@ def test_move_no_wait(self): self._assert_equal_objects(actual_param, expected_param) self.assertEqual(ret.ref, execute_response) - def test_move_strict_permission_denied(self): - task = Object(msg='permission denied') - self._services.tasks.wait = mock.MagicMock(return_value=task) - self._init_services(execute_response=self._task_reference) - with self.assertRaises(exceptions.io.core.PrivilegeError): - self._services.files.move(self._source, destination=self._dest, strict_permission=True) - self._services.tasks.wait.assert_called_once_with(self._task_reference) - - def test_move_strict_permission_denied_error_type(self): - task = Object(error_type='permissiondenied') - self._services.tasks.wait = mock.MagicMock(return_value=task) - self._init_services(execute_response=self._task_reference) - with self.assertRaises(exceptions.io.core.PrivilegeError): - self._services.files.move(self._source, destination=self._dest, strict_permission=True) - self._services.tasks.wait.assert_called_once_with(self._task_reference) - def test_move_completed_with_warnings_raises_move_error(self): task = mock.MagicMock() task.completed = False @@ -52,7 +36,7 @@ def test_move_completed_with_warnings_raises_move_error(self): self._services.tasks.wait = mock.MagicMock(return_value=task) self._init_services(execute_response=self._task_reference) with self.assertRaises(exceptions.io.core.MoveError): - self._services.files.move(self._source, destination=self._dest, strict_permission=True) + self._services.files.move(self._source, destination=self._dest) self._services.tasks.wait.assert_called_once_with(self._task_reference) def _create_move_resource_param(self): From 45f30e33b636bc12634ab412438f26509b800fc7 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Wed, 18 Feb 2026 18:28:21 -0500 Subject: [PATCH 6/8] mock permission verification to pass ut --- tests/ut/core/user/base_user.py | 1 + tests/ut/core/user/test_browser.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/ut/core/user/base_user.py b/tests/ut/core/user/base_user.py index 43f36940..5a520df6 100644 --- a/tests/ut/core/user/base_user.py +++ b/tests/ut/core/user/base_user.py @@ -13,6 +13,7 @@ def setUp(self): self._services = ServicesPortal("") self._base = '/ServicesPortal/webdav' self._task_reference = 'servers/MainDB/bgTasks/918908' + self.patch_call('cterasdk.cio.core.commands.raise_or_suppress_access_error') @staticmethod def encode_path(path): diff --git a/tests/ut/core/user/test_browser.py b/tests/ut/core/user/test_browser.py index 1e1ebc04..719536df 100644 --- a/tests/ut/core/user/test_browser.py +++ b/tests/ut/core/user/test_browser.py @@ -18,6 +18,7 @@ def setUp(self): self.directory_path = 'a/b c/d' self.filename = 'Document.txt' self.new_filename = 'Summary.txt' + self.patch_call('cterasdk.cio.core.commands.raise_or_suppress_access_error') def test_versions(self): directory = 'Users/John Smith/My Files' From 2afdc76f456446f2c3db725861d83a0428e01fe2 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Wed, 18 Feb 2026 18:44:41 -0500 Subject: [PATCH 7/8] update to pass lint --- docs/source/UserGuides/Miscellaneous/Changelog.rst | 2 +- tests/ut/core/user/test_mkdir.py | 4 ---- tests/ut/core/user/test_move.py | 3 --- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/source/UserGuides/Miscellaneous/Changelog.rst b/docs/source/UserGuides/Miscellaneous/Changelog.rst index aee3127c..d57b10dd 100644 --- a/docs/source/UserGuides/Miscellaneous/Changelog.rst +++ b/docs/source/UserGuides/Miscellaneous/Changelog.rst @@ -9,7 +9,7 @@ Improvements * Capture the logged-on user's permission to access user folders * Add ability to: - + - Compute paths based on slices - Compute paths relative to another path - Verify whether a path is relative to another path diff --git a/tests/ut/core/user/test_mkdir.py b/tests/ut/core/user/test_mkdir.py index f88d4c99..0cf72e15 100644 --- a/tests/ut/core/user/test_mkdir.py +++ b/tests/ut/core/user/test_mkdir.py @@ -1,9 +1,5 @@ from unittest import mock import munch - -from cterasdk.common import Object -from cterasdk import exceptions - from tests.ut.core.user import base_user diff --git a/tests/ut/core/user/test_move.py b/tests/ut/core/user/test_move.py index a66144fa..b2fb96fe 100644 --- a/tests/ut/core/user/test_move.py +++ b/tests/ut/core/user/test_move.py @@ -1,8 +1,5 @@ from unittest import mock - -from cterasdk.common import Object from cterasdk import exceptions - from tests.ut.core.user import base_user From 213869e22a081f3121c38262b064ac80c5fd1764 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Wed, 18 Feb 2026 18:47:53 -0500 Subject: [PATCH 8/8] update docs for cloud folder finding helpers --- docs/source/UserGuides/Portal/Administration.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/UserGuides/Portal/Administration.rst b/docs/source/UserGuides/Portal/Administration.rst index 801806a7..8df4b212 100644 --- a/docs/source/UserGuides/Portal/Administration.rst +++ b/docs/source/UserGuides/Portal/Administration.rst @@ -1542,12 +1542,12 @@ To manage zones, you must be a Read Write Administrator """ Add the following cloud folders to zone: 'ZN-001' - 1) 'Accounting' folder owned by 'Bruce' - 2) 'HR' folder owned by 'Diana' + 1) 'Accounting' folder owned by 'Alice' + 2) 'HR' folder owned by 'Bob' """ - accounting = core_types.CloudFSFolderFindingHelper('Accounting', 'Bruce') - hr = core_types.CloudFSFolderFindingHelper('HR', 'Diana') + accounting = core_types.CloudFSFolderFindingHelper('Accounting', core_types.UserAccount('Alice')) + hr = core_types.CloudFSFolderFindingHelper('HR', core_types.UserAccount('Bob')) admin.cloudfs.zones.add_folders('ZN-001', [accounting, hr])