diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 432c3e92a..a1904b884 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,24 @@ History All release highlights of this project will be documented in this file. +4.6.0 - Aug 16, 2026 +____________________ + +**Added** + + - ``SAClient.grant_team_user_permissions()`` Grants permissions to a team user, with support for granting specific permissions or all available permissions based on the user's role. + - ``SAClient.revoke_team_user_permissions()`` Revokes permissions from a team user, with support for revoking specific permissions or all available permissions based on the user's role. + +**Updated** + + - ``SAClient.upload_annotations()`` Added the ``integration`` parameter to sign private external URLs when creating new items in Multimodal projects. The integration must already exist on SuperAnnotate and is supported only when ``data_spec="multimodal"``. + - ``SAClient(token=)`` Added support for authentication using both Personal API Keys and Team API Keys. + +**Removed** + + - ``SAClient.search_team_contributors()`` Recommended replacements: ``SAClient.get_item_metadata()`` or ``SAClient.list_items()`` + - ``SAClient.search_items()`` Recommended replacements: ``SAClient.get_user_metadata()`` or ``SAClient.list_users()`` + 4.5.9 - July 21, 2026 _____________________ **Updated** @@ -15,6 +33,11 @@ _____________________ 4.5.8 - July 12, 2026 _____________________ +**Added** + + - ``SAClient.grant_team_user_permissions()`` Grants team-level user permissions. + - ``SAClient.revoke_team_user_permissions()`` Revokes team-level user permissions. + **Updated** - ``SAClient.generate_items()`` The name key now supports values with up to 200 characters. diff --git a/README.rst b/README.rst index f191ff4ad..5df84946d 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Authentication # by environment variable SA_TOKEN sa_client = SAClient() # by token - sa_client = SAClient(token='') + sa_client = SAClient(token='') # by config file # default path is ~/.superannotate/config.ini sa_client = SAClient(config_path='~/.superannotate/dev_config.ini') @@ -42,8 +42,9 @@ config.ini example .. code-block:: python [DEFAULT] - SA_TOKEN = + SA_TOKEN = LOGGING_LEVEL = INFO + LOGGING_PATH = /Users/username/data/superannotate_logs Using superannotate @@ -54,7 +55,7 @@ Using superannotate from superannotate import SAClient - sa_client =SAClient() + sa_client = SAClient() project = 'Dogs' @@ -110,34 +111,6 @@ Windows (`Anaconda `__ ) platforms. -Supported Features ------------------- - -- search/get/create/clone/update/delete projects -- search/get/create/delete folders -- assign folders to project contributors -- upload items to a project from a local or AWS S3 folder -- attach items by URL or from an integrated storage, meanwhile keeping them secure in your cloud provider -- get integrated cloud storages -- upload annotations (also from local or AWS S3 folder) -- delete annotations -- set items annotations statuses -- get/download/export annotations from a project (also to a local or AWS S3 folder) -- invite/search team contributors or add contributors to a specific project -- search/get/copy/move items in a project -- query items using SA Query Language -- define custom metadata for items and upload custom values (query based on your custom metadata) -- upload priority scores -- get available subsets (sets of segregated items), query items in a subset or add items to a subset -- assign or anassign items to project contributors -- download an image that has been uploaded to project -- search/create/download/delete project annotation classes -- search/download models -- run predictions -- convert annotations from/to COCO format -- convert annotation from VOC, SuperVisely, LabelBox, DataLoop, VGG, VoTT, SageMaker, GoogleCloud, YOLO formats -- CLI commands for simple tasks - Questions and Issues -------------------- diff --git a/docs/source/api_reference/api_item.rst b/docs/source/api_reference/api_item.rst index e25f57894..b7aa1851c 100644 --- a/docs/source/api_reference/api_item.rst +++ b/docs/source/api_reference/api_item.rst @@ -6,7 +6,6 @@ Items .. automethod:: superannotate.SAClient.query .. automethod:: superannotate.SAClient.get_item_by_id .. automethod:: superannotate.SAClient.list_items -.. automethod:: superannotate.SAClient.search_items .. automethod:: superannotate.SAClient.attach_items .. automethod:: superannotate.SAClient.generate_items .. automethod:: superannotate.SAClient.item_context diff --git a/docs/source/api_reference/api_team.rst b/docs/source/api_reference/api_team.rst index 6178dcf75..d34c1b0f3 100644 --- a/docs/source/api_reference/api_team.rst +++ b/docs/source/api_reference/api_team.rst @@ -7,7 +7,6 @@ Team .. automethod:: superannotate.SAClient.list_workflows .. automethod:: superannotate.SAClient.get_integrations .. automethod:: superannotate.SAClient.invite_contributors_to_team -.. automethod:: superannotate.SAClient.search_team_contributors .. automethod:: superannotate.SAClient.get_user_metadata .. automethod:: superannotate.SAClient.set_user_custom_field .. automethod:: superannotate.SAClient.list_users @@ -18,3 +17,5 @@ Team .. automethod:: superannotate.SAClient.set_user_scores .. automethod:: superannotate.SAClient.set_contributors_categories .. automethod:: superannotate.SAClient.remove_contributors_categories +.. automethod:: superannotate.SAClient.grant_team_user_permissions +.. automethod:: superannotate.SAClient.revoke_team_user_permissions diff --git a/docs/source/cli_client.rst b/docs/source/cli_client.rst index 6fb5c7ccf..976fab709 100644 --- a/docs/source/cli_client.rst +++ b/docs/source/cli_client.rst @@ -22,11 +22,11 @@ ________________________ Initialization and configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To initialize CLI (and SDK) with team token: +To initialize CLI (and SDK) with an API key: .. code-block:: bash - superannotatecli init --token + superannotatecli init --token [--logging_level ] [--logging_path ] diff --git a/docs/source/userguide/SDK_Functions_sheet.csv b/docs/source/userguide/SDK_Functions_sheet.csv index 434f0fdc7..8c929f56d 100644 --- a/docs/source/userguide/SDK_Functions_sheet.csv +++ b/docs/source/userguide/SDK_Functions_sheet.csv @@ -31,7 +31,6 @@ Folders,search_folders(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant Items,query(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,get_item_by_id(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,list_items(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant -,search_items(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,attach_items(),Not Relevant,Not Relevant,No,No,Not Relevant ,item_context(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,copy_items(),Yes,Not Relevant,Not Relevant,Not Relevant,Not Relevant @@ -74,7 +73,6 @@ Images,download_image(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Team,get_team_metadata(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,get_integrations(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,invite_contributors_to_team(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant -,search_team_contributors(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,get_user_metadata(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,set_user_custom_field(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant ,list_users(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index fa4ed7ece..5a72270eb 100644 --- a/docs/source/userguide/quickstart.rst +++ b/docs/source/userguide/quickstart.rst @@ -31,8 +31,16 @@ It can be installed on Ubuntu with: Initialization and authorization ================================ -To use the SDK, you need to create a config file with a team-specific authentication token. The token is available -to team admins on the team settings page at https://doc.superannotate.com/docs/token-for-python-sdk#generate-a-token-for-python-sdk. +To use the SDK, you need to create a config file with an API key. The API key is available to team owners and team admins +on the team setup page, for more details please visit our documentation at https://doc.superannotate.com/docs/api-keys. + +**API key types** + +- **Team API key** — scoped to one team. Works with ``SAClient``. +- **Personal (team-user) API key** — scoped to one team, tied to your user. Works with ``SAClient``. +- **Organization API key** — not scoped to a team. Not supported by the SDK; + ``SAClient`` will reject it. + SAClient can be used with or without arguments ______________________________________________ @@ -52,7 +60,7 @@ ______________________________________________ .. code-block:: bash - superannotatecli init --token + superannotatecli init --token [--logging_level ] [--logging_path ] @@ -66,7 +74,7 @@ ______________________________________________ from superannotate import SAClient - SAClient(token="") + sa_client = SAClient(token="") *Method 2:* Create a custom config file: @@ -84,7 +92,7 @@ Custom config.ini example: .. code-block:: ini [DEFAULT] - SA_TOKEN = + SA_TOKEN = LOGGING_LEVEL = INFO LOGGING_PATH = /Users/username/data/superannotate_logs diff --git a/pytest.ini b/pytest.ini index d9f7f6cc3..c0f66b58e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -;addopts = -n 6 --dist loadscope +addopts = -n 6 --dist loadscope diff --git a/requirements.txt b/requirements.txt index e91a61669..0a042baf1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ pydantic~=2.5 pydantic-extra-types~=2.11 -aiohttp~=3.8 +aiohttp~=3.14 boto3~=1.42 opencv-python-headless~=4.7 plotly~=6.5 pandas~=2.0 -pillow~=12.1 +pillow~=12.3 tqdm~=4.66 requests~=2.33 aiofiles~=25.1 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 5075f8805..f090dd866 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.9" +__version__ = "4.6.0dev1" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py index 35da00364..2171bf707 100644 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py +++ b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py @@ -199,7 +199,7 @@ def get_image_dimensions(self, image_path): else: try: img = Image.open(image_path) - img_width, img_height = img.size() + img_width, img_height = img.size except Exception as e: raise diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 37ab812fb..4b0db4080 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -124,11 +124,14 @@ def get_mp_instance(self) -> Mixpanel: @staticmethod @lru_cache - def get_default_payload(team_name, user_email): + def get_default_payload(team_name, user_email, auth_type): return { "SDK": True, "Team": team_name, "User Email": user_email, + # How the client authenticated: "sdk" for a legacy team token, + # "api_key" for a scoped API key. + "Auth Type": auth_type, "Version": os.environ["sa_version"], "Python version": platform.python_version(), "Python interpreter type": platform.python_implementation(), @@ -205,11 +208,12 @@ def _track_method(self, args, kwargs, success: bool): arguments = self.extract_arguments(self.function, *args, **kwargs) event_name, properties = self.default_parser(function_name, arguments) user_email = client.controller.current_user.email - team_name = client.controller.team_data.name + team_name = client.controller.team_name + auth_type = client.controller.token_context.auth_type properties["Success"] = success default = self.get_default_payload( - team_name=team_name, user_email=user_email + team_name=team_name, user_email=user_email, auth_type=auth_type ) self._track( user_email, diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index 80c2536b9..4940581cf 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -33,10 +33,10 @@ def init( logging_path: str = constances.LOG_FILE_LOCATION, ): """ - To initialize CLI (and SDK) with team token - Input the team SDK token from https://app.superannotate.com/team + To initialize CLI (and SDK) with API key. + The API key is available on the team setup page, for more details please visit our documentation at https://doc.superannotate.com/docs/api-keys. - :param token: the team token + :param token: the API key :type token: str :param logging_level: logging level, default is "INFO" diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index ee9403039..587992093 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -295,7 +295,7 @@ class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta): In case of no argument has been provided, SA_TOKEN environmental variable will be checked or $HOME/.superannotate/config.json will be used. - :param token: team token + :param token: API key :type token: str :param config_path: path to config file @@ -303,11 +303,7 @@ class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta): """ - def __init__( - self, - token: str | None = None, - config_path: str | None = None, - ): + def __init__(self, token: str | None = None, config_path: str | None = None): super().__init__(token, config_path) def get_project_by_id(self, project_id: int): @@ -1223,6 +1219,161 @@ def revoke_project_user_permissions( operation="revoke", ) + def grant_team_user_permissions( + self, + permissions: list[NotEmptyStr] | Literal["*"], + user: int | str, + ) -> None: + """ + Grants permissions for a team user. Accepts "*" to indicate all available + team-level permissions based on the user's role. + + :param permissions: Specifies which permissions to grant. + Accepts "*" to indicate all available team user permissions. + + Possible values are + + - "Access team API keys": Only for Team Admins. Allows Team Admins to + create, rotate, and revoke team API keys which have full Team Admin + permissions. If this permission is set, it will automatically grant + access to all the other admin permissions. + - "Access Orchestrate": Only for Team Admins. Allows Team Admins to create and + monitor Orchestrate pipelines, as well as access Secrets and Proxies. + - "Revoke members' personal API keys": Only for Team Admins. Allows Team + Admins to revoke personal API keys generated by other members. + - "Manage Contributors' permissions": Only for Team Contributors. Grants + all contributor permissions and the ability to manage other + contributors' permissions. If this permission is set, it will + automatically grant access to all the other contributor permissions. + - "Invite Contributors to team": Only for Team Contributors. Allows + inviting contributors to the team. + - "Remove Contributors from team": Only for Team Contributors. Allows + removing contributors from the team. + - "View Contributors' scores": Only for Team Contributors. Allows viewing + contributors' scores. + - "View Contributors' custom field values": Only for Team Contributors. + Allows viewing contributors' custom field values. + - "Edit Contributors' custom field values": Only for Team Contributors. + Allows editing contributors' custom field values. If this permission is + set, it will automatically grant access to "View Contributors' custom + field values" permission. + - "Access Workload management": Only for Team Contributors. Allows + accessing Workload management. + :type permissions: Union[List[str], Literal["*"]] + + :param user: Team user ID or email to grant permissions to. + :type user: Union[int, str] + + :rtype: None + + :raises AppException: If permissions are empty or the user is not found. + + Request Example: + :: + + # To grant a specific permission by email: + sa_client.grant_team_user_permissions( + permissions=["Access team API keys"], + user="test@superannotate.com" + ) + + # To grant all permissions by team user ID: + sa_client.grant_team_user_permissions( + permissions="*", + user=124341 + ) + + # Example when granting "Edit Contributors' custom field values" permission + sa_client.grant_team_user_permissions( + permissions=["Edit Contributors' custom field values"], + user="test@superannotate.com" + ) + + # Example when granting "Manage Contributors' permissions" permission + sa_client.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], + user="test@superannotate.com" + ) + """ + if not permissions: + raise AppException("Permission(s) cannot be empty.") + self.controller.work_management.edit_team_user_permissions( + user=user, + permissions=permissions, + operation="grant", + ) + + def revoke_team_user_permissions( + self, + permissions: list[NotEmptyStr] | Literal["*"], + user: int | str, + ) -> None: + """ + Revokes permissions for a team user. Accepts "*" to indicate all available + team-level permissions based on the user's role. + + :param permissions: Specifies which permissions to revoke. + Accepts "*" to indicate all available team user permissions. Possible + values are the same as for + :func:`grant_team_user_permissions`. + + The following rules apply when revoking: + + - "Access team API keys" grants every other Team Admin permission, so + no Team Admin permission can be revoked while it is still granted; + revoke "Access team API keys" first. + - "Manage Contributors' permissions" grants every other Team + Contributor permission, so no Team Contributor permission can be + revoked while it is still granted; revoke "Manage Contributors' + permissions" first. + - Revoking either of the above revokes only that permission; the + permissions it implied stay granted. + - Revoking "View Contributors' custom field values" will also revoke + "Edit Contributors' custom field values". + :type permissions: Union[List[str], Literal["*"]] + + :param user: Team user ID or email to revoke permissions from. + :type user: Union[int, str] + + :rtype: None + + :raises AppException: If permissions are empty or the user is not found. + + Request Example: + :: + + # To revoke a specific permission by email: + sa_client.revoke_team_user_permissions( + permissions=["Remove Contributors from team"], + user="contributor@superannotate.com" + ) + + # To revoke all permissions by team user ID: + sa_client.revoke_team_user_permissions( + permissions="*", + user=124341 + ) + + # Example when revoking "View Contributors' custom field values" permission + sa_client.revoke_team_user_permissions( + permissions=["View Contributors' custom field values"], + user="test@superannotate.com" + ) + + # Example when revoking "Manage Contributors' permissions" permission + sa_client.revoke_team_user_permissions( + permissions=["Manage Contributors' permissions"], + user="test@superannotate.com" + ) + """ + if not permissions: + raise AppException("Permission(s) cannot be empty.") + self.controller.work_management.edit_team_user_permissions( + user=user, + permissions=permissions, + operation="revoke", + ) + def get_component_config(self, project: NotEmptyStr | int, component_id: str): """ Retrieves the configuration for a given project and component ID. @@ -1279,44 +1430,6 @@ def retrieve_context( raise AppException("No component context found for project.") return _context - def search_team_contributors( - self, - email: EmailStr | None = None, - first_name: NotEmptyStr | None = None, - last_name: NotEmptyStr | None = None, - return_metadata: bool = True, - ): - """Search for contributors in the team - - :param email: filter by email - :type email: str - - :param first_name: filter by first name - :type first_name: str - - :param last_name: filter by last name - :type last_name: str - - :param return_metadata: return metadata of contributors instead of names - :type return_metadata: bool - - :return: metadata of found users - :rtype: list of dicts - """ - warnings.warn( - "This function search_team_contributors() will be deprecated and removed in version 4.6.0\n" - "Recommended replacement: get_user_metadata() or list_users()", - DeprecationWarning, - stacklevel=2, - ) - contributors = self.controller.search_team_contributors( - email=email, first_name=first_name, last_name=last_name - ).data - - if not return_metadata: - return [contributor["email"] for contributor in contributors] - return contributors - def search_projects( self, name: NotEmptyStr | None = None, @@ -1345,7 +1458,7 @@ def search_projects( :rtype: list of strs or dicts """ warnings.warn( - "This function search_projects() will be deprecated and removed in version 4.6.0\n" + "This function search_projects() will be deprecated and removed in version 4.7.0\n" "Recommended replacement: get_project_metadata() or list_projects()", DeprecationWarning, stacklevel=2, @@ -1951,7 +2064,7 @@ def search_folders( """ warnings.warn( DeprecationWarning( - "This function search_folders() will be deprecated and removed in version 4.6.0 \n" + "This function search_folders() will be deprecated and removed in version 4.7.0 \n" "Recommended replacement:list_folders()" ) ) @@ -3702,6 +3815,7 @@ def upload_annotations( keep_status: bool | None = None, *, data_spec: Literal["default", "multimodal"] = "default", + integration: str | None = None, ): """Uploads a list of annotation dictionaries to the specified SuperAnnotate project or folder. @@ -3723,6 +3837,10 @@ def upload_annotations( compact and modality-specific data representation. :type data_spec: str, optional + :param integration: The name of an existing integration on the SuperAnnotate platform, used to access external URLs in the annotations. Only supported for + Multimodal projects and data_spec="multimodal" and only applies to items being newly created — it has no effect on existing items. + :type integration: str, optional + :return: A dictionary containing the results of the upload, categorized into successfully uploaded, failed, and skipped annotations. :rtype: dict @@ -3759,6 +3877,17 @@ def upload_annotations( keep_status=True, data_spec='multimodal' ) + + Example Usage with private URLs signed via an integration:: + + # Upload annotations with private URLs using integration + sa_client.upload_annotations( + project="project1/folder1", + annotations=annotations, + keep_status=True, + data_spec="multimodal", + integration="AWS Main Bucket" + ) """ if keep_status is not None: warnings.warn( @@ -3768,6 +3897,18 @@ def upload_annotations( ) ) project, folder = self.controller.get_project_folder(project) + integration_entity = None + if integration: + if data_spec != "multimodal" or project.type != ProjectType.MULTIMODAL: + raise AppException( + "Integration is only supported for Multimodal projects" + ) + for i in self.controller.integrations.list().data: + if i.name == integration: + integration_entity = i + break + else: + raise AppException("Integration not found") response = self.controller.annotations.upload_multiple( project=project, folder=folder, @@ -3775,6 +3916,7 @@ def upload_annotations( keep_status=keep_status, user=self.controller.current_user, output_format=data_spec, + integration=integration_entity, ) if response.errors: raise AppException(response.errors) @@ -4598,128 +4740,6 @@ def get_item_metadata( return BaseSerializer(item).serialize(exclude=exclude) - def search_items( - self, - project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], - name_contains: NotEmptyStr | None = None, - annotation_status: str | None = None, - annotator_email: NotEmptyStr | None = None, - qa_email: NotEmptyStr | None = None, - recursive: bool = False, - include_custom_metadata: bool = False, - ): - """Search items by filtering criteria. - - :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” - :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] - - :param name_contains: returns those items, where the given string is found anywhere within an item’s name. - If None, all items returned, in accordance with the recursive=False parameter. - :type name_contains: str - - :param annotation_status: returns items with the specified annotation status, which must match a predefined - status in the project workflow. If None, all items are returned. - - :type annotation_status: str - - :param annotator_email: returns those items’ names that are assigned to the specified annotator. - If None, all items are returned. Strict equal. - :type annotator_email: str - - :param qa_email: returns those items’ names that are assigned to the specified QA. - If None, all items are returned. Strict equal. - :type qa_email: str - - :param recursive: search in the project’s root and all of its folders. - If False search only in the project’s root or given directory. - :type recursive: bool - - :param include_custom_metadata: include custom metadata that has been attached to an asset. - :type include_custom_metadata: bool - - :return: metadata of item - :rtype: list of dicts - - Request Example: - :: - - sa_client.search_items( - project="Medical Annotations", - name_contains="image_1", - include_custom_metadata=True - ) - - Response Example: - :: - - [ - { - "name": "image_1.jpeg", - "path": "Medical Annotations/Study", - "url": "https://sa-public-files.s3.../image_1.png", - "annotation_status": "NotStarted", - "annotator_email": None, - "qa_email": None, - "entropy_value": None, - "createdAt": "2022-02-15T20:46:44.000Z", - "updatedAt": "2022-02-15T20:46:44.000Z", - "custom_metadata": { - "study_date": "2021-12-31", - "patient_id": "62078f8a756ddb2ca9fc9660", - "patient_sex": "female", - "medical_specialist": "robertboxer@ms.com", - } - } - ] - """ - warnings.warn( - "This function search_items() will be deprecated and removed in version 4.6.0\n" - "Recommended replacement: get_item_metadata() or list_items()", - DeprecationWarning, - stacklevel=2, - ) - project, folder = self.controller.get_project_folder(project) - query_kwargs = {"include": ["assignments"]} - if name_contains: - query_kwargs["name__contains"] = name_contains - if annotation_status: - query_kwargs["annotation_status"] = annotation_status - if qa_email: - query_kwargs["assignments__user_id"] = qa_email - query_kwargs["assignments__user_role"] = "QA" - if annotator_email: - query_kwargs["assignments__user_id"] = annotator_email - query_kwargs["assignments__user_role"] = "Annotator" - if folder.is_root and recursive: - items = [] - for folder in self.controller.folders.list(project=project).data: - path = ( - f"{project.name}{f'/{folder.name}' if not folder.is_root else ''}" - ) - _items = self.controller.items.list_items( - project, - folder, - **query_kwargs, - ) - for i in _items: - i.path = path - items.extend(_items) - else: - path = f"{project.name}{f'/{folder.name}' if not folder.is_root else ''}" - items = self.controller.items.list_items(project, folder, **query_kwargs) - for i in items: - i.path = path - exclude = {"meta"} - if include_custom_metadata: - item_custom_fields = self.controller.custom_fields.list_fields( - project=project, item_ids=[i.id for i in items] - ) - for i in items: - i.custom_metadata = item_custom_fields[i.id] - else: - exclude.add("custom_metadata") - return BaseSerializer.serialize_iterable(items, exclude=exclude) - def list_items( self, project: NotEmptyStr | int, @@ -5683,7 +5703,7 @@ def upload_custom_values( ): """ Attach custom metadata to items. - SAClient.get_item_metadata(), SAClient.search_items(), SAClient.query() methods + SAClient.get_item_metadata(), SAClient.list_items(), SAClient.query() methods will return the item metadata and custom metadata. :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 3d8866724..c03d4852e 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -153,6 +153,40 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): INVALID_JSON_MESSAGE = "Invalid json" +# Team-user permission cascade rules, keyed by permission id. The +# work-management backend does not auto-cascade through the permissions API, +# so the SDK mirrors the documented cascades client-side. +# +# A "master" permission implies every other permission in its own group: +# granting it grants the whole group, and while it stays granted no permission +# in that group can be revoked. The master itself stays revocable (otherwise it +# would lock permanently); revoking it leaves the permissions it implied +# granted. Each team-user permission group has at most one master. +TEAM_USER_PERMISSION_MASTERS = { + 19: "Manage Contributors’ permissions", # Team contributor permissions + 29: "Access team API keys", # Team admin permissions +} +# Still returned by the permission-groups API but no longer grantable +# (superseded by "Access team API keys"): the backend silently refuses it both +# individually and inside a batch. Excluded from "*" expansion and from master +# cascades so it never surfaces as a spurious grant failure. +TEAM_USER_PERMISSION_DEPRECATED_IDS = frozenset({26}) # View SDK Token +# Granting "Edit Contributors' custom field values" also grants "View +# Contributors' custom field values". The master cascades (granting a master +# grants every other permission in its group) are NOT hardcoded here: they are +# derived at runtime from the live permission-groups data so they only include +# permissions that actually exist for the team (e.g. id 25 may be absent +# depending on the team's configuration). +TEAM_USER_PERMISSION_GRANT_CASCADE = { + 24: [23], +} +# Revoking "View Contributors' custom field values" also revokes "Edit +# Contributors' custom field values". +TEAM_USER_PERMISSION_REVOKE_CASCADE = { + 23: [24], +} + + PROJECT_SETTINGS_VALID_ATTRIBUTES = [ "Brightness", "Fill", diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 008f6ea62..2208b60d3 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -101,12 +101,24 @@ def map_fields(entity: dict) -> dict: return entity +#: Legacy team-owner token: ``=`` — the team is part of the token. TOKEN_PATTERN = re.compile(r"^[-.@_A-Za-z0-9]+=\d+$") +#: New-style API key (team / team-user / organization scoped). Its scope is not in the +#: token, it is resolved via the work-management ``users/me`` endpoint. Matched by shape +#: rather than by the ``sa_`` prefix so future prefixes keep working; the length bound +#: keeps malformed input ("INVALID_TOKEN") reported as an invalid token instead of +#: being sent to the backend. +API_KEY_PATTERN = re.compile(r"^[-_A-Za-z0-9]{32,}$") + + +def is_legacy_token(value: str) -> bool: + """Whether the token carries its team id, as opposed to being a scoped API key.""" + return bool(TOKEN_PATTERN.match(value)) def _validate_token(value: str) -> str: """Validate token format.""" - if not TOKEN_PATTERN.match(value): + if not is_legacy_token(value) and not API_KEY_PATTERN.match(value): raise ValueError("Invalid token.") return value diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 54960f5aa..64fb30579 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -2,6 +2,7 @@ import io from abc import ABC +from abc import ABCMeta from abc import abstractmethod from collections.abc import Callable from typing import Any @@ -42,11 +43,20 @@ class BaseClient(ABC): - def __init__(self, api_url: str, token: str): - self.team_id = token.split("=")[-1] + DEFAULT_AUTH_TYPE = "sdk" + + def __init__( + self, + api_url: str, + token: str, + team_id: int, + auth_type: str = DEFAULT_AUTH_TYPE, + ): + self.team_id = team_id self._api_url = api_url self._token = token + self._auth_type = auth_type @property def api_url(self): @@ -56,6 +66,10 @@ def api_url(self): def token(self): return self._token + @property + def auth_type(self): + return self._auth_type + @property @abstractmethod def default_headers(self): @@ -71,8 +85,8 @@ def paginate( url: str, item_type: Any, chunk_size: int = 2000, - query_params: dict[str, Any] = None, - headers: dict = None, + query_params: dict[str, Any] | None = None, + headers: dict | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -80,10 +94,10 @@ def paginate( def jsx_paginate( self, url: str, - method: str = Literal["get", "post"], - body_query: Query = None, - query_params: dict = None, - headers: dict = None, + method: Literal["get", "post"] = "post", + body_query: Query | None = None, + query_params: dict | None = None, + headers: dict | None = None, chunk_size: int = 100, item_type: Any = None, ) -> ServiceResponse: @@ -121,7 +135,7 @@ def list_custom_field_templates( self, entity: CustomFieldEntityEnum, parent_entity: CustomFieldEntityEnum, - context: dict = None, + context: dict | None = None, ): raise NotImplementedError @@ -169,7 +183,7 @@ def list_users( body_query: Query, parent_entity: CustomFieldEntityEnum = CustomFieldEntityEnum.TEAM, chunk_size=100, - project_id: int = None, + project_id: int | None = None, include_custom_fields=False, ) -> WMUserListResponse: raise NotImplementedError @@ -259,6 +273,14 @@ def edit_project_user_permissions( ) -> dict: raise NotImplementedError + @abstractmethod + def set_team_user_permissions( + self, + contributor_id: int, + permission_ids: list[int], + ) -> list[int]: + raise NotImplementedError + @abstractmethod def update_annotation_class( self, @@ -291,7 +313,7 @@ def get_editor_template( raise NotImplementedError @abstractmethod - def list(self, condition: Condition = None) -> ProjectListResponse: + def list(self, condition: Condition | None = None) -> ProjectListResponse: raise NotImplementedError @abstractmethod @@ -391,7 +413,7 @@ def create( raise NotImplementedError @abstractmethod - def list(self, condition: Condition = None) -> FolderListResponse: + def list(self, condition: Condition | None = None) -> FolderListResponse: raise NotImplementedError @abstractmethod @@ -434,7 +456,7 @@ def create_multiple( raise NotImplementedError @abstractmethod - def list(self, condition: Condition = None) -> ServiceResponse: + def list(self, condition: Condition | None = None) -> ServiceResponse: raise NotImplementedError @abstractmethod @@ -461,7 +483,7 @@ def attach( attachments: list[Attachment], upload_state_code, annotation_status_code=None, - meta: dict[str, AttachmentMeta] = None, + meta: dict[str, AttachmentMeta] | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -557,7 +579,7 @@ async def get_big_annotation( project: entities.ProjectEntity, item: entities.BaseItemEntity, reporter: Reporter, - transform_version: str = None, + transform_version: str | None = None, ) -> dict: raise NotImplementedError @@ -568,8 +590,8 @@ async def list_small_annotations( folder: entities.FolderEntity, item_ids: list[int], reporter: Reporter, - callback: Callable = None, - transform_version: str = None, + callback: Callable | None = None, + transform_version: str | None = None, ) -> list[dict]: raise NotImplementedError @@ -588,8 +610,8 @@ async def download_big_annotation( project: entities.ProjectEntity, download_path: str, item: entities.BaseItemEntity, - callback: Callable = None, - transform_version: str = None, + callback: Callable | None = None, + transform_version: str | None = None, ): raise NotImplementedError @@ -601,8 +623,8 @@ async def download_small_annotations( reporter: Reporter, download_path: str, item_ids: list[int], - callback: Callable = None, - transform_version: str = None, + callback: Callable | None = None, + transform_version: str | None = None, ): raise NotImplementedError @@ -612,7 +634,7 @@ async def upload_small_annotations( project: entities.ProjectEntity, folder: entities.FolderEntity, items_name_data_map: dict[str, dict], - transform_version: str = None, + transform_version: str | None = None, ) -> UploadAnnotationsResponse: raise NotImplementedError @@ -624,7 +646,7 @@ async def upload_big_annotation( item_id: int, data: io.StringIO, chunk_size: int, - transform_version: str = None, + transform_version: str | None = None, ) -> bool: raise NotImplementedError @@ -632,8 +654,8 @@ async def upload_big_annotation( def delete( self, project: entities.ProjectEntity, - folder: entities.FolderEntity = None, - item_names: list[str] = None, + folder: entities.FolderEntity | None = None, + item_names: list[str] | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -666,7 +688,7 @@ def set_item_annotations( data: dict, overwrite: bool, transform_version: str = "llmJsonV2", - etag: str = None, + etag: str | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -682,8 +704,8 @@ def attach_items( project: entities.ProjectEntity, folder: entities.FolderEntity, integration: entities.IntegrationEntity, - folder_name: str = None, - options: dict[str, str] = None, + folder_name: str | None = None, + options: dict[str, str] | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -729,7 +751,7 @@ def delete_values( @abstractmethod def list_subsets( - self, project: entities.ProjectEntity, condition: Condition = None + self, project: entities.ProjectEntity, condition: Condition | None = None ): raise NotImplementedError @@ -756,9 +778,9 @@ def validate_saqul_query( def saqul_query( self, project: entities.ProjectEntity, - folder: entities.FolderEntity = None, - query: str = None, - subset_id: int = None, + folder: entities.FolderEntity | None = None, + query: str | None = None, + subset_id: int | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -766,9 +788,9 @@ def saqul_query( def query_item_count( self, project: entities.ProjectEntity, - folder: entities.FolderEntity = None, - query: str = None, - subset_id: int = None, + folder: entities.FolderEntity | None = None, + query: str | None = None, + subset_id: int | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -787,7 +809,7 @@ def set_score_values( raise NotImplementedError -class BaseServiceProvider: +class BaseServiceProvider(metaclass=ABCMeta): projects: BaseProjectService folders: BaseFolderService items: BaseItemService @@ -884,8 +906,8 @@ def prepare_export( include_fuse: bool, only_pinned: bool, integration_id: int, - annotation_statuses: list[str] = None, - export_type: int = None, + annotation_statuses: list[str] | None = None, + export_type: int | None = None, ) -> ServiceResponse: raise NotImplementedError @@ -914,7 +936,15 @@ def get_project_images_count( raise NotImplementedError @abstractmethod - def search_team_contributors(self, condition: Condition = None) -> ServiceResponse: + def get_team_user_permission_id(self, name: str) -> int | None: + raise NotImplementedError + + @abstractmethod + def get_team_user_permission_id_name_map(self) -> dict[int, str]: + raise NotImplementedError + + @abstractmethod + def get_team_user_permission_groups(self) -> dict[str, dict[int, str]]: raise NotImplementedError @abstractmethod diff --git a/src/superannotate/lib/core/usecases/__init__.py b/src/superannotate/lib/core/usecases/__init__.py index 7d34f674e..6e305a825 100644 --- a/src/superannotate/lib/core/usecases/__init__.py +++ b/src/superannotate/lib/core/usecases/__init__.py @@ -7,3 +7,4 @@ from lib.core.usecases.items import * # noqa: F403 F401 from lib.core.usecases.models import * # noqa: F403 F401 from lib.core.usecases.projects import * # noqa: F403 F401 +from lib.core.usecases.work_management import * # noqa: F403 F401 diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 3b5d6f481..9946174fb 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -30,6 +30,7 @@ from lib.core.entities import ConfigEntity from lib.core.entities import FolderEntity from lib.core.entities import ImageEntity +from lib.core.entities import IntegrationEntity from lib.core.entities import ProjectEntity from lib.core.entities import UserEntity from lib.core.exceptions import AppException @@ -130,8 +131,8 @@ async def upload_small_annotations( service_provider: BaseServiceProvider, reporter: Reporter, report: Report, - callback: Callable = None, - transform_version: str = None, + callback: Callable | None = None, + transform_version: str | None = None, ): async def upload(_chunk: list[ItemToUpload]): failed_annotations, missing_classes, missing_attr_groups, missing_attrs = ( @@ -161,8 +162,8 @@ async def upload(_chunk: list[ItemToUpload]): if callback: for i in chunk: callback(i) - except Exception: - logger.debug(traceback.print_exc()) + except Exception as e: + logger.debug(e) failed_annotations.extend([i.item.name for i in chunk]) finally: report.failed_annotations.extend(failed_annotations) @@ -436,7 +437,7 @@ def execute(self): "failed": failed, "skipped": skipped, } - return self._response + return self._response class UploadAnnotationsFromFolderUseCase(BaseReportableUseCase): @@ -609,17 +610,17 @@ def get_existing_name_item_mapping( @property def annotation_upload_data(self) -> UploadAnnotationAuthData: - CHUNK_SIZE = UploadAnnotationsFromFolderUseCase.CHUNK_SIZE_PATHS + chunk_size = UploadAnnotationsFromFolderUseCase.CHUNK_SIZE_PATHS if self._annotation_upload_data: return self._annotation_upload_data images = {} - for i in range(0, len(self._item_ids), CHUNK_SIZE): + for i in range(0, len(self._item_ids), chunk_size): tmp = self._service_provider.get_annotation_upload_data( project=self._project, folder=self._folder, - item_ids=self._item_ids[i : i + CHUNK_SIZE], + item_ids=self._item_ids[i : i + chunk_size], ) if not tmp.ok: raise AppException(tmp.error) @@ -1190,10 +1191,8 @@ def oneOf(validator, oneOf, instance, schema): # noqa else: subschemas = enumerate(oneOf) all_errors = [] - for index, subschema in subschemas: - errs = list( - validator.descend(instance, subschema, schema_path=index) - ) + for idx, subschema in subschemas: + errs = list(validator.descend(instance, subschema, schema_path=idx)) if not errs: break all_errors.extend(errs) @@ -1253,7 +1252,7 @@ def iter_errors(self, instance, _schema=None): errors = validator(self, v, instance, _schema) or () for error in errors: # set details if not already set by the called fn - error._set( + error._set( # noqa validator=k, validator_value=v, instance=instance, @@ -1745,6 +1744,7 @@ def __init__( user: UserEntity, keep_status: bool = False, transform_version: str = None, + integration: IntegrationEntity = None, ): super().__init__(reporter) self._project = project @@ -1758,6 +1758,7 @@ def __init__( self._transform_version = ( "llmJsonV2" if transform_version is None else transform_version ) + self._integration = integration self._category_name_to_id_map = {} @property @@ -1895,7 +1896,12 @@ def attach_items( project=self._project, folder=folder, attachments=[ - AttachmentEntity(name=item_name, url="") for item_name in item_names + AttachmentEntity( + name=item_name, + url="custom_llm", # hardcoded for multimodal items + integration_id=self._integration.id if self._integration else None, + ) + for item_name in item_names ], service_provider=self._service_provider, ).execute() @@ -2042,7 +2048,7 @@ def execute(self): "failed": failed, "skipped": skipped, } - return self._response + return self._response def _attach_categories(self, folder_id: int, item_id_category_map: dict[int, str]): categories_to_create: list[str] = [] diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 0a165d877..302c5de75 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -806,24 +806,6 @@ def execute(self): return self._response -class SearchContributorsUseCase(BaseUseCase): - def __init__( - self, - service_provider: BaseServiceProvider, - team_id: int, - condition: Condition = None, - ): - super().__init__() - self._service_provider = service_provider - self._team_id = team_id - self._condition = condition - - def execute(self): - res = self._service_provider.search_team_contributors(self._condition) - self._response.data = res.data - return self._response - - class AddContributorsToProject(BaseUseCase): """ Returns tuple of lists (added, skipped) diff --git a/src/superannotate/lib/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py new file mode 100644 index 000000000..d0096ac77 --- /dev/null +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Literal + +import lib.core as constants +from lib.core.entities.work_managament import WMUserTypeEnum +from lib.core.reporter import Reporter +from lib.core.response import Response +from lib.core.serviceproviders import BaseServiceProvider +from lib.core.usecases import BaseReportableUseCase + +PermissionOperation = Literal["grant", "revoke"] + +# Permission ids used by the cascade rules (see constants). +MASTER_IDS = frozenset(constants.TEAM_USER_PERMISSION_MASTERS) +DEPRECATED_IDS = constants.TEAM_USER_PERMISSION_DEPRECATED_IDS +CONTRIBUTOR_MASTER_ID = 19 +# Master named in the revoke failure hint when the failed permissions cannot be +# traced back to a group (e.g. they are all unresolved names). Defaults to the +# contributor master, which is the pre-existing wording. +DEFAULT_MASTER_NAME = constants.TEAM_USER_PERMISSION_MASTERS[CONTRIBUTOR_MASTER_ID] + + +class UpdateUserPermissionUseCase(BaseReportableUseCase): + """Grant or revoke team-user permissions for a single user. + + The backend endpoint (``teamusers/setpermissions``) is declarative: it + replaces the user's whole permission set with the list we send. Grant and + revoke are therefore implemented as read-modify-write on that set, while + this use case keeps the business rules the endpoint does not enforce: + + - "*" resolves only to the permissions allowed for the user's role + (a role-invalid permission makes the backend reject the whole set); + - permission names are matched case- and apostrophe-insensitively; + - documented cascades are mirrored client-side (see + ``constants.TEAM_USER_PERMISSION_GRANT_CASCADE`` / + ``TEAM_USER_PERMISSION_REVOKE_CASCADE``) because the backend does not + auto-cascade; + - each group's master permission (see + ``constants.TEAM_USER_PERMISSION_MASTERS``: "Manage Contributors' + permissions" for contributors, "Access team API keys" for admins) + implies every other permission in its own group: whenever a master + stays in the desired set we add the rest of its group (this also + preserves the rule that a group's permissions cannot be revoked while + its master is enabled); + - permissions the backend no longer grants + (``constants.TEAM_USER_PERMISSION_DEPRECATED_IDS``) are left out of + ``"*"`` and of master cascades so they are not reported as failures; + - per-permission success / failure is reported through the reporter. + Permissions the SDK added itself (master and Edit/View cascades, and the + ``"*"`` expansion) are only reported as failures when they genuinely did + not reach the requested state: one that was already there is the normal + case, so it stays silent. A permission the caller named explicitly is + always reported, which is how "User already has ..." is surfaced. + """ + + def __init__( + self, + reporter: Reporter, + user: int | str, + permissions: list[str] | Literal["*"], + operation: PermissionOperation, + service_provider: BaseServiceProvider, + user_resolver: Callable[[int | str], list], + ): + super().__init__(reporter) + self._user = user + self._permissions = permissions + self._operation = operation + self._service_provider = service_provider + self._user_resolver = user_resolver + + def execute(self) -> Response: + if not self._permissions: + self._response.errors = "Permission(s) cannot be empty." + return self._response + + team_users = self._user_resolver(self._user) + if not team_users: + self._response.errors = "User not found." + return self._response + + team_user = team_users[0] + name_by_id = self._service_provider.get_team_user_permission_id_name_map() + groups = self._groups() + current_ids = [ + p.id for p in (team_user.user_permissions or []) if p.id is not None + ] + + resolved_ids, unresolved_names, role_mismatch_names = self._resolve_permissions( + team_user.role, name_by_id, groups, current_ids + ) + + # Permissions the caller named one by one. With "*" no specific + # permission was named, so the whole expansion counts as implicit. + explicit_ids = set() if self._permissions == "*" else set(resolved_ids) + + # The group that applies to this user's role. The master rules are scoped + # to it so a permission the user holds from outside their role (stale + # data after a role change) can never pull in another group's ids. + role_group = ( + self._role_team_user_permission_map(team_user.role, name_by_id, groups) + if groups + else None + ) + + # The permissions we attempted to change (requested + cascade), used for + # per-permission success / failure reporting. + cascade = self._build_cascade(self._operation, groups, set(name_by_id)) + attempted_ids = self._cascade_team_permission_ids(resolved_ids, cascade) + + desired_ids = self._desired_permission_ids( + current_ids, attempted_ids, role_group + ) + + # Skip the network round-trip when nothing would change. + if set(desired_ids) == set(current_ids): + new_state = set(current_ids) + else: + new_state = self._apply(team_user.id, desired_ids) + + self._log( + current_ids, + new_state, + attempted_ids, + unresolved_names, + role_mismatch_names, + team_user.email, + role_group, + explicit_ids, + ) + return self._response + + def _groups(self) -> dict[str, dict[int, str]] | None: + try: + return self._service_provider.get_team_user_permission_groups() + except Exception: + return None + + def _desired_permission_ids( + self, + current_ids: list[int], + attempted_ids: list[int], + role_group: dict[int, str] | None, + ) -> list[int]: + """Full permission set to send, derived from the current set. + + Grant unions the attempted ids into the current set; revoke subtracts + them. The master invariant is applied last so that a set still holding + the master keeps its whole group (and members cannot be revoked while + the master is enabled). + """ + current = set(current_ids) + if self._operation == "grant": + desired = current | set(attempted_ids) + else: + desired = current - set(attempted_ids) + desired = self._apply_master_invariant(desired, role_group) + return sorted(desired) + + @staticmethod + def _group_master(perms: dict[int, str]) -> int | None: + """The master permission id of a group, if the group has one.""" + return next((pid for pid in perms if pid in MASTER_IDS), None) + + @classmethod + def _apply_master_invariant( + cls, desired: set[int], role_group: dict[int, str] | None + ) -> set[int]: + """Re-add the role's permissions whenever its master stays granted. + + Applied to the desired set of every operation, this enforces both master + rules at once: granting a master pulls in its whole group, and a group's + permissions cannot be revoked while its master is still granted (the + subtraction is undone here, so the desired set ends up unchanged and the + revoke is reported as a failure). + + Only the group matching the user's role is considered, so a permission + held from outside that role never drags in another group's ids. + """ + if not role_group: + return desired + master_id = cls._group_master(role_group) + if master_id is not None and master_id in desired: + desired = desired | (set(role_group) - DEPRECATED_IDS) + return desired + + def _apply(self, contributor_id: int, permission_ids: list[int]) -> set[int]: + return set( + self._service_provider.work_management.set_team_user_permissions( + contributor_id=contributor_id, + permission_ids=permission_ids, + ) + ) + + def _resolve_permissions( + self, + role: WMUserTypeEnum, + name_by_id: dict[int, str], + groups: dict[str, dict[int, str]] | None, + current_perm_ids: list[int], + ) -> tuple[list[int], list[str], list[str]]: + # Permissions valid for the user's role. When the role groups cannot be + # fetched this falls back to the full map, deferring role enforcement + # to the backend. + role_ids = set( + self._role_team_user_permission_map(role, name_by_id, groups).keys() + ) + if self._permissions == "*": + if self._operation == "revoke": + # revoke "*" clears the permissions the user currently holds + # (including the master, now that it is removable). Resolve to + # the held permissions so the desired set becomes empty. + # Deprecated ids are not filtered here: a user who somehow holds + # one should still be able to clear it. + return [pid for pid in current_perm_ids if pid in role_ids], [], [] + # grant "*" skips permissions the backend no longer grants, and is + # sorted so the reported order is deterministic. + return sorted(role_ids - DEPRECATED_IDS), [], [] + + resolved_ids: list[int] = [] + seen_ids: set[int] = set() + unresolved_names: list[str] = [] + role_mismatch_names: list[str] = [] + for name in self._permissions: + pid = self._service_provider.get_team_user_permission_id(name) + if pid is None: + unresolved_names.append(name) + elif pid not in role_ids: + # Valid permission name, but not allowed for this user's role + # (e.g. an admin permission requested for a contributor, or + # vice versa). Don't send it to the backend; report it as a + # role-mismatch failure using the canonical name. + role_mismatch_names.append(name_by_id[pid]) + elif pid not in seen_ids: + resolved_ids.append(pid) + seen_ids.add(pid) + return resolved_ids, unresolved_names, role_mismatch_names + + @classmethod + def _master_name(cls, role_group: dict[int, str] | None) -> str: + """Canonical name of the master that governs this user's permissions. + + Keeps the revoke hint scoped to the user's role: a contributor is never + told to revoke the admin master, and vice versa. Falls back to the + contributor master when the role's group is unavailable. + """ + if role_group: + master_id = cls._group_master(role_group) + if master_id is not None: + return role_group[master_id] + return DEFAULT_MASTER_NAME + + def _log( + self, + current_ids: list[int], + new_state: set[int], + attempted_ids: list[int], + unresolved_names: list[str], + role_mismatch_names: list[str], + user_email: str, + role_group: dict[int, str] | None = None, + explicit_ids: set[int] | None = None, + ) -> None: + name_by_id = self._service_provider.get_team_user_permission_id_name_map() + current = set(current_ids) + # Permissions whose state actually changed in the intended direction. + if self._operation == "grant": + changed = new_state - current + else: + changed = current - new_state + + def is_reportable(pid: int) -> bool: + """Whether an unchanged permission is worth reporting as a failure. + + A permission the caller named is always reported (that is how + "User already has ..." is surfaced). One the SDK added itself - + through a master or Edit/View cascade, or by expanding "*" - is only + reported when it genuinely failed to reach the requested state. + Already being in that state is the normal case, not a problem. + """ + if explicit_ids is None or pid in explicit_ids: + return True + already_ok = ( + pid in current if self._operation == "grant" else pid not in current + ) + return not already_ok + + failed_ids = [ + pid for pid in attempted_ids if pid not in changed and is_reportable(pid) + ] + succeeded_names = [name_by_id[pid] for pid in attempted_ids if pid in changed] + failed_names = ( + [name_by_id[pid] for pid in failed_ids] + + role_mismatch_names + + unresolved_names + ) + + verb_inf = "grant" if self._operation == "grant" else "revoke" + verb_past = "granted" if self._operation == "grant" else "revoked" + + if succeeded_names: + self.reporter.log_info( + f"Successfully {verb_past} [{', '.join(succeeded_names)}] " + f"permission(s) for user: {user_email}." + ) + if failed_names: + failed_str = f"[{', '.join(failed_names)}]" + if self._operation == "grant": + reasons = ( + f"- User already has {failed_str} permission(s) granted.\n" + f"- User role does not allow {failed_str} permission(s).\n" + "- Provided permission(s) were invalid.\n" + "- The API key used does not have sufficient permissions to perform this action." + ) + else: + # Revoking a group's permissions is blocked while its master is + # granted, so the hint names the master governing this user. + master_name = self._master_name(role_group) + reasons = ( + f"- {failed_str} permission(s) were already revoked for the user.\n" + f"- Provided permission(s) were invalid.\n" + f"- If {master_name} is granted, it must be revoked before " + f"{failed_str} can be revoked for this user.\n" + "- The API key used does not have sufficient permissions to perform this action." + ) + self.reporter.log_info( + f"Could not {verb_inf} {failed_str} permission(s) " + f"for user: {user_email}.\nPossible reasons:\n{reasons}" + ) + + @staticmethod + def _role_team_user_permission_map( + role: WMUserTypeEnum, + full_map: dict[int, str], + groups: dict[str, dict[int, str]] | None, + ) -> dict[int, str]: + """Return the subset of team-user permissions available for the role.""" + if not groups: + return dict(full_map) + keyword = "contributor" if role == WMUserTypeEnum.Contributor else "admin" + for group_name, perms in groups.items(): + if keyword in group_name.lower(): + return dict(perms) + return dict(full_map) + + def _build_cascade( + self, + operation: PermissionOperation, + groups: dict[str, dict[int, str]] | None, + known_ids: set[int], + ) -> dict[int, list[int]]: + # Start from the hardcoded name-based cascades (e.g. Edit -> View + # custom field values), then derive each group's master cascade from the + # live permission-groups data: granting a master grants every other + # permission in its group. Deriving it at runtime avoids hardcoding ids + # that may not exist for every team (e.g. id 25 can be absent depending + # on configuration) and keeps non-grantable ids out of the batch. + base = ( + constants.TEAM_USER_PERMISSION_GRANT_CASCADE + if operation == "grant" + else constants.TEAM_USER_PERMISSION_REVOKE_CASCADE + ) + # The hardcoded cascades name ids by convention, but a team may not have + # them all. Drop anything this team does not expose, otherwise we would + # send unknown ids to the backend and fail to name them when reporting. + cascade = { + pid: [dep for dep in deps if dep in known_ids] + for pid, deps in base.items() + if pid in known_ids + } + if operation == "grant" and groups: + for perms in groups.values(): + master_id = self._group_master(perms) + if master_id is None: + continue + cascade[master_id] = [ + pid + for pid in perms + if pid != master_id and pid not in DEPRECATED_IDS + ] + return cascade + + @staticmethod + def _cascade_team_permission_ids( + requested: list[int], cascade: dict[int, list[int]] + ) -> list[int]: + """Expand requested permission ids with cascade dependents (by id).""" + expanded = list(requested) + seen = set(requested) + for pid in list(requested): + for dep_id in cascade.get(pid, []): + if dep_id not in seen: + expanded.append(dep_id) + seen.add(dep_id) + return expanded diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index c14cd69e5..2da47bf7a 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -10,7 +10,7 @@ from typing import Any from typing import Literal -import lib.core as constances +import lib.core as constants from lib.core import ApprovalStatus from lib.core import usecases from lib.core.conditions import Condition @@ -60,6 +60,8 @@ from lib.infrastructure.query_builder import TeamUserFilterHandler from lib.infrastructure.repositories import S3Repository from lib.infrastructure.serviceprovider import ServiceProvider +from lib.infrastructure.services.auth import resolve_token_context +from lib.infrastructure.services.auth import TokenContext from lib.infrastructure.services.http_client import HttpClient from lib.infrastructure.utils import divide_to_chunks from lib.infrastructure.utils import extract_project_folder @@ -608,11 +610,40 @@ def edit_project_user_permissions( f"for user: {user_email}.\nPossible reasons:\n{reasons}" ) + def edit_team_user_permissions( + self, + user: int | str, + permissions: list[str] | Literal["*"], + operation: Literal["grant", "revoke"], + ): + use_case = usecases.UpdateUserPermissionUseCase( + reporter=Reporter(), + user=user, + permissions=permissions, + operation=operation, + service_provider=self.service_provider, + user_resolver=self._resolve_team_user, + ) + response = use_case.execute() + if response.errors: + raise AppException(response.errors) + + def _resolve_team_user(self, user: int | str): + if isinstance(user, int): + return self.list_users(id__in=[user]) + return self.list_users(email__in=[user]) + class ProjectManager(BaseManager): - def __init__(self, service_provider: ServiceProvider, team: TeamEntity): + def __init__( + self, service_provider: ServiceProvider, team: Callable[[], TeamEntity] + ): super().__init__(service_provider) - self._team = team + self._get_team = team + + @property + def _team(self) -> TeamEntity: + return self._get_team() def get_by_id(self, project_id): use_case = usecases.GetProjectByIDUseCase( @@ -1432,6 +1463,7 @@ def upload_multiple( keep_status: bool, user: UserEntity, output_format: str = None, + integration: IntegrationEntity = None, ): if project.type == ProjectType.MULTIMODAL and output_format == "multimodal": use_case = usecases.UploadMultiModalAnnotationsUseCase( @@ -1443,6 +1475,7 @@ def upload_multiple( keep_status=keep_status, user=user, transform_version="llmJsonV2", + integration=integration, ) else: use_case = usecases.UploadAnnotationsUseCase( @@ -1635,7 +1668,6 @@ def __init__(self, config: ConfigEntity): self._logger = logging.getLogger("sa") self._testing = os.getenv("SA_TESTING", "False").lower() in ("true", "1", "t") self._token = config.API_TOKEN - self._team_data = None self._s3_upload_auth_data = None self._projects = None self._folders = None @@ -1646,15 +1678,28 @@ def __init__(self, config: ConfigEntity): self._user_id = None self._reporter = None + self._token_context = resolve_token_context( + api_url=config.API_URL, + token=config.API_TOKEN, + verify_ssl=config.VERIFY_SSL, + ) + self._team_id = self._token_context.team_id + http_client = HttpClient( - api_url=config.API_URL, token=config.API_TOKEN, verify_ssl=config.VERIFY_SSL + api_url=config.API_URL, + token=config.API_TOKEN, + team_id=self._team_id, + auth_type=self._token_context.auth_type, + verify_ssl=config.VERIFY_SSL, ) self.service_provider = ServiceProvider(http_client) self._user = self.get_current_user() - self._team = self.get_team().data + # An API key already resolved its team, so the team data is only fetched once + # something actually needs it (the organization id, mostly). + self._team = self.get_team().data if self._token_context.is_legacy else None self.annotation_classes = AnnotationClassManager(self.service_provider) - self.projects = ProjectManager(self.service_provider, team=self._team) + self.projects = ProjectManager(self.service_provider, team=lambda: self.team) self.work_management = WorkManagementManager(self.service_provider) self.folders = FolderManager(self.service_provider) self.items = ItemManager(self.service_provider) @@ -1669,20 +1714,21 @@ def reporter(self): @property def org_id(self): - return self._team.owner_id + return self.team.owner_id @property def current_user(self): return self._user @property - def user_id(self): - if not self._user_id: - self._user_id, _ = self.get_team() - return self._user_id + def token_context(self) -> TokenContext: + """The scope the client authenticated with (team / team-user / legacy).""" + return self._token_context @property - def team(self): + def team(self) -> TeamEntity: + if self._team is None: + self._team = self.get_team().data return self._team def get_team(self): @@ -1691,6 +1737,10 @@ def get_team(self): ).execute() def get_current_user(self) -> UserEntity: + # An API key resolves its own user (or its creator, for team-scoped keys) while the + # team is being resolved, so there is nothing left to look up. + if self._token_context.user: + return self._token_context.user response = usecases.GetCurrentUserUseCase( service_provider=self.service_provider, team_id=self.team_id ).execute() @@ -1699,16 +1749,19 @@ def get_current_user(self) -> UserEntity: return response.data @property - def team_data(self): - if not self._team_data: - self._team_data = self.team - return self._team_data + def team_name(self) -> str: + """The name of the team the client operates in. + + Used by telemetry. An API key resolves only the team id on init, so the first + call fetches the team; the result is cached on the controller from then on. + """ + return self.team.name @property def team_id(self) -> int: - if not self._token: + if not self._token or not self._team_id: raise AppException("Invalid credentials provided.") - return int(self._token.split("=")[-1]) + return self._team_id @staticmethod def get_default_reporter( @@ -1896,15 +1949,6 @@ def delete_exports( ) return use_case.execute() - def search_team_contributors(self, **kwargs): - condition = build_condition(**kwargs) - use_case = usecases.SearchContributorsUseCase( - service_provider=self.service_provider, - team_id=self.team_id, - condition=condition, - ) - return use_case.execute() - def _get_image( self, project: ProjectEntity, @@ -2014,7 +2058,7 @@ def consensus( def validate_annotations(self, project_type: str, annotation: dict): use_case = usecases.ValidateAnnotationUseCase( reporter=self.get_default_reporter(), - project_type=constances.ProjectType(project_type).value, + project_type=constants.ProjectType(project_type).value, annotation=annotation, team_id=self.team_id, service_provider=self.service_provider, diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 3b1d7a386..5cfb60f27 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -5,7 +5,6 @@ import lib.core as constants from lib.core import entities -from lib.core.conditions import Condition from lib.core.enums import ApprovalStatus from lib.core.enums import CustomFieldEntityEnum from lib.core.service_types import TeamResponse @@ -60,6 +59,8 @@ def __init__(self, client: HttpClient): HttpClient( api_url=self._get_work_management_url(client), token=client.token, + team_id=client.team_id, + auth_type=client.auth_type, verify_ssl=client.verify_ssl, ) ) @@ -67,6 +68,8 @@ def __init__(self, client: HttpClient): HttpClient( api_url=self._get_item_service_url(client), token=client.token, + team_id=client.team_id, + auth_type=client.auth_type, verify_ssl=client.verify_ssl, ) ) @@ -166,6 +169,21 @@ def get_project_user_permission_id_name_map(self) -> dict[int, str]: self.client.team_id ) + def get_team_user_permission_id(self, name: str) -> int | None: + return self._cached_work_management_repository.get_team_user_permission_id( + self.client.team_id, name + ) + + def get_team_user_permission_id_name_map(self) -> dict[int, str]: + return self._cached_work_management_repository.get_team_user_permission_id_name_map( + self.client.team_id + ) + + def get_team_user_permission_groups(self) -> dict[str, dict[int, str]]: + return self._cached_work_management_repository.get_team_user_permission_groups( + self.client.team_id + ) + @staticmethod def _get_work_management_url(client: HttpClient): if client.api_url != constants.BACKEND_URL: @@ -336,17 +354,11 @@ def get_project_images_count(self, project: entities.ProjectEntity): params={"project_id": project.id}, ) - def search_team_contributors(self, condition: Condition = None): - list_users_url = self.URL_USERS - if condition: - list_users_url = f"{list_users_url}?{condition.build_query()}" - return self.client.paginate(list_users_url) - def invite_contributors(self, team_id: int, team_role: int, emails: list[str]): return self.client.request( self.URL_INVITE_CONTRIBUTORS.format(team_id), "post", - data=dict(emails=emails, team_role=team_role), + data={"emails": emails, "team_role": team_role}, ) def create_custom_workflow(self, org_id: str, data: dict): diff --git a/src/superannotate/lib/infrastructure/services/auth.py b/src/superannotate/lib/infrastructure/services/auth.py new file mode 100644 index 000000000..81bf61355 --- /dev/null +++ b/src/superannotate/lib/infrastructure/services/auth.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import lib.core as constants +import requests +from lib.core.entities.base import is_legacy_token +from lib.core.entities.project import UserEntity +from lib.core.exceptions import AppException + +logger = logging.getLogger("sa") + +SDK_AUTH_TYPE = "sdk" +API_KEY_AUTH_TYPE = "api_key" + +URL_TOKEN_CONTEXT = "users/me" + +#: A key issued for the team itself, with no user behind it. It acts on the team's +#: behalf, so the backend denies operations that only a user can perform (changing a +#: team admin's permissions, for one). +TEAM_SCOPE_TYPE = "team" +#: A key issued for one user of a team; it acts as that user. +TEAM_USER_SCOPE_TYPE = "teamuser" +#: Token scopes that carry a team, and therefore need no explicit team_id. +TEAM_SCOPED_TYPES = (TEAM_SCOPE_TYPE, TEAM_USER_SCOPE_TYPE) + +ORGANIZATION_API_KEY_ERROR = ( + "SAClient does not accept an Organization API key — it requires a Team or " + "Personal API key." +) +AUTHENTICATION_ERROR = ( + "Unable to authenticate the provided token. Please verify your credentials." +) + + +@dataclass +class TokenContext: + """The team the client operates in, plus the user acting behind the token.""" + + team_id: int + auth_type: str + user: UserEntity | None = None + #: Scope the key was issued for ("team", "teamuser", "organization"); None for a + #: legacy token, whose scope is not reported by the backend. + scope_type: str | None = None + + @property + def is_legacy(self) -> bool: + return self.auth_type == SDK_AUTH_TYPE + + @property + def is_team_key(self) -> bool: + """Whether the token acts as the team rather than as a user.""" + return self.scope_type == TEAM_SCOPE_TYPE + + @property + def is_personal_key(self) -> bool: + """Whether the token acts as one specific user of the team.""" + return self.scope_type == TEAM_USER_SCOPE_TYPE + + +def resolve_token_context( + api_url: str, + token: str, + verify_ssl: bool = True, +) -> TokenContext: + """Resolve the team (and acting user) a token grants access to. + + Legacy team-owner tokens carry the team id, so they are resolved offline. New-style + API keys are resolved against the work-management service, which reports the scope + the key was issued for. The SDK operates within a single team, so a key that is not + scoped to one is rejected. + """ + if is_legacy_token(token): + return TokenContext(team_id=int(token.split("=")[-1]), auth_type=SDK_AUTH_TYPE) + + data = _fetch_token_context(api_url, token, verify_ssl) + token_data = data.get("token") or {} + scope = token_data.get("scope") or {} + scope_type = token_data.get("scope_type") + token_team_id = scope.get("team_id") + + # Anything outside the allowlist (an organization key, today) has no team to operate + # in; the team_id check keeps a malformed response from resolving to no team at all. + if scope_type not in TEAM_SCOPED_TYPES or token_team_id is None: + logger.debug(f"Rejected a token of {scope_type} scope.") + raise AppException(ORGANIZATION_API_KEY_ERROR) + + logger.debug(f"Token resolved to {scope_type} scope, team {token_team_id}.") + return TokenContext( + team_id=int(token_team_id), + auth_type=API_KEY_AUTH_TYPE, + user=_build_user(data.get("user"), token_data.get("created_by")), + scope_type=scope_type, + ) + + +def _get_work_management_url(api_url: str) -> str: + # The token scope has to be resolved before there is a client to ask, so the + # work-management host is derived here as well as in the service provider. + if api_url != constants.BACKEND_URL: + return "https://work-management-api.devsuperannotate.com/api/v1/" + return "https://work-management-api.superannotate.com/api/v1/" + + +def _fetch_token_context(api_url: str, token: str, verify_ssl: bool) -> dict: + url = f"{_get_work_management_url(api_url)}{URL_TOKEN_CONTEXT}" + try: + response = requests.post( + url, + json={}, + headers={ + "Authorization": token, + "authtype": API_KEY_AUTH_TYPE, + "Content-Type": "application/json", + }, + verify=verify_ssl, + ) + except (requests.RequestException, ConnectionError) as e: + raise AppException(f"Unable to authenticate the provided token: {e}.") + if not response.ok: + logger.debug( + f"Got {response.status_code} response from backend: {response.text}" + ) + raise AppException(AUTHENTICATION_ERROR) + try: + return response.json() + except ValueError: + raise AppException(AUTHENTICATION_ERROR) + + +def _build_user(user: dict | None, created_by: str | None) -> UserEntity | None: + """A team-scoped key has no user behind it, so it falls back to its creator.""" + if user: + return UserEntity(**user) + if created_by: + return UserEntity(id=created_by, email=created_by) + return None diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index 75be8357c..a74e7c8d4 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -43,10 +43,15 @@ def default(self, obj): class HttpClient(BaseClient): - AUTH_TYPE = "sdk" - - def __init__(self, api_url: str, token: str, verify_ssl: bool = True): - super().__init__(api_url, token) + def __init__( + self, + api_url: str, + token: str, + team_id: int, + auth_type: str = BaseClient.DEFAULT_AUTH_TYPE, + verify_ssl: bool = True, + ): + super().__init__(api_url, token, team_id, auth_type) self._verify_ssl = verify_ssl self._version = os.environ.get("sa_version") self._env = os.environ.get("SA_ENV") @@ -76,7 +81,7 @@ def get_session(self): def default_headers(self): return { "Authorization": self._token, - "authtype": self.AUTH_TYPE, + "authtype": self._auth_type, "Content-Type": "application/json", "x-sa-entity-context": base64.b64encode( json.dumps( @@ -118,7 +123,6 @@ def _request(self, url, method, session, retried=0, **kwargs): ) prepared = session.prepare_request(req) response = session.send(request=prepared, verify=self._verify_ssl) - if response.status_code == 404 and retried < 3: time.sleep(retried * 0.1) return self._request( @@ -126,7 +130,7 @@ def _request(self, url, method, session, retried=0, **kwargs): ) if response.status_code > 299: logger.debug( - f"Got {response.status_code} response from backend: {response.text}" + f"Got {response.status_code} from {method} {url} response from backend:, {response.text}" ) return response diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 0a4d2b7d7..1eb571e43 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -77,8 +77,8 @@ class WorkManagementService(BaseWorkManagementService): URL_SEARCH_PROJECT_USERS = "projectusers/search" URL_SEARCH_PROJECTS = "projects/search" URL_RESUME_PAUSE_USER = "teams/editprojectsusers" - URL_CONTRIBUTORS_CATEGORIES = "customentities/edit" - URL_EDIT_PROJECT_USER_PERMISSIONS = "customentities/edit" + URL_EDIT_CUSTOM_ENTITIES = "customentities/edit" + URL_SET_TEAM_USER_PERMISSIONS = "teamusers/setpermissions" URL_PERMISSION_GROUPS = "permissiongroups" URL_UPDATE_ANNOTATION_CLASS = "classes/{class_id}" @@ -483,7 +483,7 @@ def create_score( method="post", headers={ "x-sa-entity-context": self._generate_context( - team_id=int(self.client.team_id) # TODO delete int after BED fix + team_id=int(self.client.team_id) ), }, data=data, @@ -525,7 +525,7 @@ def set_remove_contributor_categories( body_query = EmptyQuery() body_query &= Filter("id", chunk, OperatorEnum.IN) response = self.client.request( - url=self.URL_CONTRIBUTORS_CATEGORIES, + url=self.URL_EDIT_CUSTOM_ENTITIES, method="post", params=params, data={ @@ -577,7 +577,7 @@ def edit_project_user_permissions( body_query = EmptyQuery() body_query &= Filter("id", chunk, OperatorEnum.IN) response = self.client.request( - url=self.URL_EDIT_PROJECT_USER_PERMISSIONS, + url=self.URL_EDIT_CUSTOM_ENTITIES, method="post", params=params, data={ @@ -599,6 +599,41 @@ def edit_project_user_permissions( return affected + def set_team_user_permissions( + self, + contributor_id: int, + permission_ids: list[int], + ) -> list[int]: + """Declaratively set a team user's permissions to exactly ``permission_ids``. + + The backend replaces the user's whole ``userPermissions`` set with the + provided list (unlike the old add/remove delta endpoint, this can also + remove the otherwise-irreversible "Manage Contributors' permissions" + master). Returns the resulting permission ids as reported by the backend. + """ + response = self.client.request( + url=self.URL_SET_TEAM_USER_PERMISSIONS, + method="post", + data={ + "query": {"search": {"id": {"$eq": contributor_id}}}, + "body": {"userPermissions": [{"id": i} for i in permission_ids]}, + }, + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id, + ), + }, + ) + response.raise_for_status() + data = response.data.get("data") or [] + entry = next( + (c for c in data if c.get("id") == contributor_id), + data[0] if data else None, + ) + if not entry: + return [] + return [p["id"] for p in (entry.get("userPermissions") or [])] + def update_annotation_class( self, project_id: int, diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index 490cb767b..6295f8828 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -20,6 +20,8 @@ logger = logging.getLogger("sa") +INVALID_PATH_ERROR = "Invalid project path" + class EntityContext(typing.TypedDict, total=False): team_id: int @@ -49,9 +51,9 @@ def extract_project_folder(user_input: str | dict) -> tuple[str, str | None]: if isinstance(user_input, dict): project_path = user_input.get("name") if not project_path: - raise PathError("Invalid project path") + raise PathError(INVALID_PATH_ERROR) return split_project_path(user_input["name"]) - raise PathError("Invalid project path") + raise PathError(INVALID_PATH_ERROR) def extract_project_folder_inputs(user_input: str | dict | tuple | int) -> dict: @@ -80,14 +82,14 @@ def extract_project_folder_inputs(user_input: str | dict | tuple | int) -> dict: if isinstance(user_input, dict): project_path = user_input.get("name") if not project_path: - raise PathError("Invalid project path") + raise PathError(INVALID_PATH_ERROR) project_name, folder_name = split_project_path(project_path) return { "project_name": project_name, "folder_name": folder_name, "project_value_type": "dict", } - raise PathError("Invalid project path") + raise PathError(INVALID_PATH_ERROR) def async_retry_on_generator( @@ -274,35 +276,50 @@ def get(self, key, **kwargs): return self._K_V_map[key] -class ProjectUserPermissionCache(BaseCachedWorkManagementRepository): +class UserPermissionCache(BaseCachedWorkManagementRepository): DEFAULT_TTL_SECONDS = 600 - def __init__(self, work_management: WorkManagementService): + def __init__(self, work_management: WorkManagementService, label: str): super().__init__(self.DEFAULT_TTL_SECONDS, work_management) + self._label = label + + @staticmethod + def _normalize_name(name: str) -> str: + # Normalize curly apostrophes (U+2018/U+2019) to straight ones so that + # permission names can be matched case- and apostrophe-insensitively. + return name.replace("\u2019", "'").replace("\u2018", "'").lower() def sync(self, team_id): response = self.work_management.list_permission_groups() if not response.ok: raise AppException(response.error) - project_user_permissions = [ + user_permissions = [ perm for group in (response.data or []) - if group.label == "projectUser" + if group.label == self._label for perm in (group.permissions or []) ] id_name_map = { - p.id: p.name - for p in project_user_permissions - if p.id is not None and p.name + p.id: p.name for p in user_permissions if p.id is not None and p.name } name_id_lower_map = { - p.name.lower(): p.id - for p in project_user_permissions + self._normalize_name(p.name): p.id + for p in user_permissions if p.id is not None and p.name } + groups: dict[str, dict[int, str]] = {} + for group in response.data or []: + if group.label != self._label or not group.name: + continue + groups[group.name] = { + p.id: p.name + for p in (group.permissions or []) + if p.id is not None and p.name + } self._K_V_map[team_id] = { "id_name_map": id_name_map, "name_id_lower_map": name_id_lower_map, + "groups": groups, } self._update_cache_timestamp(team_id) @@ -312,6 +329,16 @@ def get(self, key, **kwargs): return self._K_V_map[key] +class ProjectUserPermissionCache(UserPermissionCache): + def __init__(self, work_management: WorkManagementService): + super().__init__(work_management, label="projectUser") + + +class TeamUserPermissionCache(UserPermissionCache): + def __init__(self, work_management: WorkManagementService): + super().__init__(work_management, label="teamUser") + + class ProjectUserCustomFieldCache(CustomFieldCache): def sync(self, project_id): response = self.work_management.list_custom_field_templates( @@ -370,15 +397,30 @@ def __init__(self, ttl_seconds: int, work_management): self._project_user_permission_cache = ProjectUserPermissionCache( work_management ) + self._team_user_permission_cache = TeamUserPermissionCache(work_management) def get_project_user_permission_id(self, team_id: int, name: str) -> int | None: data = self._project_user_permission_cache.get(team_id) - return data["name_id_lower_map"].get(name.lower()) + return data["name_id_lower_map"].get(UserPermissionCache._normalize_name(name)) def get_project_user_permission_id_name_map(self, team_id: int) -> dict[int, str]: data = self._project_user_permission_cache.get(team_id) return dict(data["id_name_map"]) + def get_team_user_permission_id(self, team_id: int, name: str) -> int | None: + data = self._team_user_permission_cache.get(team_id) + return data["name_id_lower_map"].get(UserPermissionCache._normalize_name(name)) + + def get_team_user_permission_id_name_map(self, team_id: int) -> dict[int, str]: + data = self._team_user_permission_cache.get(team_id) + return dict(data["id_name_map"]) + + def get_team_user_permission_groups( + self, team_id: int + ) -> dict[str, dict[int, str]]: + data = self._team_user_permission_cache.get(team_id) + return {name: dict(perms) for name, perms in data["groups"].items()} + def get_category_id(self, project, category_name: str) -> int: data = self._category_cache.get(project.id, project=project) if category_name in data["category_name_id_map"]: diff --git a/tests/applicatoin/custom_workflow.py b/tests/applicatoin/test_custom_workflow.py similarity index 97% rename from tests/applicatoin/custom_workflow.py rename to tests/applicatoin/test_custom_workflow.py index f88562544..bbc8db169 100644 --- a/tests/applicatoin/custom_workflow.py +++ b/tests/applicatoin/test_custom_workflow.py @@ -82,8 +82,8 @@ def annotations_path(self): @lru_cache def get_non_admin_contributor_emails(self) -> list[str]: contributor_emails = [] - for i in sa.search_team_contributors(): - if i["user_role"] != 2: # skipping admins etc + for i in sa.list_users(): + if i["role"] != "Admin": # skipping admins etc contributor_emails.append(i["email"]) return contributor_emails @@ -115,7 +115,7 @@ def step_2_attach_items(self): self.PROJECT_NAME, items_to_attach, annotation_status="Completed" # noqa ) assert len(uploaded) == count - items = sa.search_items(self.PROJECT_NAME) + items = sa.list_items(self.PROJECT_NAME) assert all(i["annotation_status"] == "Completed" for i in items) def step_3_create_annotation_classes(self): @@ -132,7 +132,7 @@ def step_4_upload_annotations(self): attached_items_count = len(attached_item_names.get()) assert len(uploaded) == attached_items_count # assert that all items have a status of "attached_items_status" - items = sa.search_items( + items = sa.list_items( self.PROJECT_NAME, annotation_status=attached_items_status.get() ) assert len(items) == attached_items_count diff --git a/tests/integration/annotations/test_annotation_upload_vector.py b/tests/integration/annotations/test_annotation_upload_vector.py index 727d3a232..6d166be5f 100644 --- a/tests/integration/annotations/test_annotation_upload_vector.py +++ b/tests/integration/annotations/test_annotation_upload_vector.py @@ -83,7 +83,7 @@ def test_annotation_folder_upload_download(self): _, _, _ = sa.upload_annotations_from_folder_to_project( self.PROJECT_NAME, self.folder_path ) - images = sa.search_items(self.PROJECT_NAME) + images = sa.list_items(self.PROJECT_NAME) with tempfile.TemporaryDirectory() as tmp_dir: for image in images: image_name = image["name"] diff --git a/tests/integration/annotations/test_get_annotations.py b/tests/integration/annotations/test_get_annotations.py index 3a2de83e4..a4c9c4092 100644 --- a/tests/integration/annotations/test_get_annotations.py +++ b/tests/integration/annotations/test_get_annotations.py @@ -52,7 +52,7 @@ def test_get_annotations_by_ids(self): _, _, _ = sa.upload_annotations_from_folder_to_project( self.PROJECT_NAME, self.folder_path ) - items = sa.search_items(self.PROJECT_NAME) + items = sa.list_items(self.PROJECT_NAME) annotations = sa.get_annotations(self._project["id"], [i["id"] for i in items]) @@ -72,8 +72,8 @@ def test_get_annotations_by_ids_with_duplicate_names(self): _, _, _ = sa.upload_annotations_from_folder_to_project( f"{self.PROJECT_NAME}/{self.FOLDER_NAME_2}", self.folder_path ) - items = sa.search_items(self.PROJECT_NAME) - folder_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_NAME_2}") + items = sa.list_items(self.PROJECT_NAME) + folder_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_NAME_2) all_items = items + folder_items annotations = sa.get_annotations( @@ -172,7 +172,7 @@ def test_get_annotations10000(self): for i in range(count) ], ) - assert len(sa.search_items(self.PROJECT_NAME)) == count + assert len(sa.list_items(self.PROJECT_NAME)) == count a = sa.get_annotations(self.PROJECT_NAME) assert len(a) == count diff --git a/tests/integration/annotations/test_preannotation_upload.py b/tests/integration/annotations/test_preannotation_upload.py index 85122330b..d450e6afd 100644 --- a/tests/integration/annotations/test_preannotation_upload.py +++ b/tests/integration/annotations/test_preannotation_upload.py @@ -28,7 +28,7 @@ def test_pre_annotation_folder_upload_download(self): ) assert len(uploaded) == 4 count_in = len(list(Path(self.folder_path).glob("*.json"))) - images = sa.search_items(self.PROJECT_NAME) + images = sa.list_items(self.PROJECT_NAME) with tempfile.TemporaryDirectory() as tmp_dir: for image in images: image_name = image["name"] diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index a86e62e8a..7eb4f9531 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -1,3 +1,4 @@ +import base64 import json import os import tempfile @@ -67,7 +68,7 @@ def test_annotation_folder_upload_download(self): assert len(uploaded) == 1 annotation = sa.get_annotations(self.PROJECT_NAME, ["example_image_1.jpg"])[0] - items = sa.search_items(self.PROJECT_NAME) + items = sa.list_items(self.PROJECT_NAME) for i in items: if i["name"] == "example_image_1.jpg": assert i["annotation_status"] == "InProgress" @@ -91,7 +92,7 @@ def test_upload_keep_true(self): ).values() assert len(uploaded) == 1 - items = sa.search_items(self.PROJECT_NAME) + items = sa.list_items(self.PROJECT_NAME) for i in items: if i["name"] == "example_image_1.jpg": assert i["annotation_status"] == "Completed" @@ -257,6 +258,71 @@ def test_upload_with_integer_names(self): f"{self.PROJECT_NAME}/test_folder", data_spec="multimodal" ) + def test_integration_not_found(self): + with open(self.JSONL_ANNOTATIONS_PATH) as f: + data = [json.loads(line) for line in f] + with self.assertRaisesRegex(AppException, "Integration not found"): + sa.upload_annotations( + self.PROJECT_NAME, + annotations=data, + data_spec="multimodal", + integration="non-existing-integration-xyz", + ) + + def test_integration_only_supported_for_multimodal_data_spec(self): + with open(self.JSONL_ANNOTATIONS_PATH) as f: + data = [json.loads(line) for line in f] + with self.assertRaisesRegex( + AppException, "Integration is only supported for Multimodal projects" + ): + sa.upload_annotations( + self.PROJECT_NAME, + annotations=data, + data_spec="default", + integration="any-integration", + ) + + def test_upload_with_existing_integration(self): + integrations = sa.get_integrations() + if not integrations: + self.skipTest("No integrations available in the team.") + integration = integrations[0] + with open(self.JSONL_ANNOTATIONS_PATH) as f: + data = [json.loads(line) for line in f] + response = sa.upload_annotations( + self.PROJECT_NAME, + annotations=data, + data_spec="multimodal", + integration=integration["name"], + ) + assert len(response["succeeded"]) == 3 + + # Newly created items must carry the integration id used to sign URLs. + # The item metadata's integration_id isn't exposed by the SDK entities, + # so query the backend directly for it. + from lib.core.jsx_conditions import EmptyQuery + from lib.core.jsx_conditions import Join + + project, folder = sa.controller.get_project_folder( + f"{self.PROJECT_NAME}/test_folder" + ) + item_service = sa.controller.service_provider.item_service + client = item_service.client + entity_context = base64.b64encode( + f'{{"team_id":{client.team_id},"project_id":{project.id},' + f'"folder_id":{folder.id}}}'.encode() + ).decode() + raw_items = client.jsx_paginate( + url=item_service.URL_LIST, + chunk_size=2000, + body_query=EmptyQuery() & Join("metadata", ["path", "integration_id"]), + method="post", + headers={"x-sa-entity-context": entity_context}, + ).data + assert len(raw_items) == 3 + for item in raw_items: + assert item["metadata"]["integration_id"] == integration["id"] + def test_download_annotations(self): with open(self.JSONL_ANNOTATIONS_PATH) as f: data = [json.loads(line) for line in f] diff --git a/tests/integration/annotations/test_upload_annotations_from_folder_to_project.py b/tests/integration/annotations/test_upload_annotations_from_folder_to_project.py index 4c13a6091..cc3e85261 100644 --- a/tests/integration/annotations/test_upload_annotations_from_folder_to_project.py +++ b/tests/integration/annotations/test_upload_annotations_from_folder_to_project.py @@ -56,7 +56,7 @@ def test_upload_keep_true(self): self.PROJECT_NAME, self.folder_path, keep_status=True ) assert len(uploaded) == 1 - items = sa.search_items(self.PROJECT_NAME) + items = sa.list_items(self.PROJECT_NAME) for i in items: if i["name"] == "example_image_1.jpg": assert i["annotation_status"] == "Completed" diff --git a/tests/integration/custom_fields/test_custom_schema.py b/tests/integration/custom_fields/test_custom_schema.py index fb06765da..6950ac964 100644 --- a/tests/integration/custom_fields/test_custom_schema.py +++ b/tests/integration/custom_fields/test_custom_schema.py @@ -99,7 +99,7 @@ def test_upload_delete_custom_values_query(self): data = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") assert data[0]["custom_metadata"] == {} - def test_upload_delete_custom_values_search_items(self): + def test_upload_delete_custom_values_list_items(self): sa.create_custom_fields(self.PROJECT_NAME, self.PAYLOAD) item_name = "test" payload = {"test": 12} @@ -108,8 +108,8 @@ def test_upload_delete_custom_values_search_items(self): self.PROJECT_NAME, [{item_name: payload}] * 10000 ) assert response == {"failed": [], "succeeded": [item_name]} - data = sa.search_items( - self.PROJECT_NAME, name_contains=item_name, include_custom_metadata=True + data = sa.list_items( + self.PROJECT_NAME, name__contains=item_name, include=["custom_metadata"] ) assert data[0]["custom_metadata"] == payload data = sa.list_items( @@ -117,25 +117,24 @@ def test_upload_delete_custom_values_search_items(self): ) assert data[0]["custom_metadata"] == payload sa.delete_custom_values(self.PROJECT_NAME, [{item_name: ["test"]}]) - data = sa.search_items( - self.PROJECT_NAME, name_contains=item_name, include_custom_metadata=True + data = sa.list_items( + self.PROJECT_NAME, name__contains=item_name, include=["custom_metadata"] ) assert data[0]["custom_metadata"] == {} - def test_search_items(self): + def test_list_items(self): sa.create_custom_fields(self.PROJECT_NAME, self.PAYLOAD) item_name = "test" payload = {"test": 12} sa.attach_items(self.PROJECT_NAME, [{"name": item_name, "url": item_name}]) sa.upload_custom_values(self.PROJECT_NAME, [{item_name: payload}] * 10000) - items = sa.search_items(self.PROJECT_NAME, include_custom_metadata=True) - assert items[0]["custom_metadata"] == payload items = sa.list_items(self.PROJECT_NAME, include=["custom_metadata"]) + assert items[0]["custom_metadata"] == payload - def test_search_items_without_custom_metadata(self): + def test_list_items_without_custom_metadata(self): item_name = "test" sa.attach_items(self.PROJECT_NAME, [{"name": item_name, "url": item_name}]) - items = sa.search_items(self.PROJECT_NAME) + items = sa.list_items(self.PROJECT_NAME) assert "custom_metadata" not in items[0] def test_get_item_metadata(self): diff --git a/tests/integration/items/test_copy_items.py b/tests/integration/items/test_copy_items.py index eaff63819..c67a2c113 100644 --- a/tests/integration/items/test_copy_items.py +++ b/tests/integration/items/test_copy_items.py @@ -49,7 +49,7 @@ def test_copy_items_from_root(self): self.PROJECT_NAME, f"{self.PROJECT_NAME}/{self.FOLDER_1}" ) assert len(skipped_items) == 0 - assert len(sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}")) == 7 + assert len(sa.list_items(self.PROJECT_NAME, self.FOLDER_1)) == 7 def test_copy_items_from_root_with_annotations(self): uploaded, _, _ = sa.attach_items(self.PROJECT_NAME, self.ATTACHMENT) @@ -61,7 +61,7 @@ def test_copy_items_from_root_with_annotations(self): self.PROJECT_NAME, f"{self.PROJECT_NAME}/{self.FOLDER_1}" ) assert len(skipped_items) == 0 - assert len(sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}")) == 2 + assert len(sa.list_items(self.PROJECT_NAME, self.FOLDER_1)) == 2 with tempfile.TemporaryDirectory() as tmp_dir: sa.download_image_annotations( f"{self.PROJECT_NAME}/{self.FOLDER_1}", self.IMAGE_NAME, tmp_dir @@ -93,7 +93,7 @@ def test_copy_items_from_folder(self): f"{self.PROJECT_NAME}/{self.FOLDER_2}", ) assert len(skipped_items) == 0 - assert len(sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}")) == 7 + assert len(sa.list_items(self.PROJECT_NAME, self.FOLDER_2)) == 7 def test_skipped_count(self): sa.create_folder(self.PROJECT_NAME, self.FOLDER_1) @@ -121,7 +121,7 @@ def test_copy_items_wrong_items_list(self): f"{self.PROJECT_NAME}/{self.FOLDER_1}", items=["as", "asd", self.IMAGE_NAME], ) - items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") + items = sa.list_items(self.PROJECT_NAME, self.FOLDER_1) assert len(items) == 1 assert items[0]["name"] == self.IMAGE_NAME assert items[0]["annotation_status"] == "Completed" @@ -163,11 +163,11 @@ def test_copy_duplicated_items_without_data_with_replace_strategy(self): " due to include_annotations=False." == cm.output[0] ) assert len(skipped_items) == 2 - folder_1_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_1_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_1) + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert len(folder_1_items) == 2 assert len(folder_2_items) == 2 - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert folder_2_items[0]["annotation_status"] == "NotStarted" assert not folder_2_items[0]["approval_status"] diff --git a/tests/integration/items/test_generate_items.py b/tests/integration/items/test_generate_items.py index 03a800b0f..ab3a1b602 100644 --- a/tests/integration/items/test_generate_items.py +++ b/tests/integration/items/test_generate_items.py @@ -77,7 +77,7 @@ def test_invalid_name(self): AppException, "Invalid item name.", ): - sa.generate_items(self.PROJECT_NAME, 100, name="a" * 115) + sa.generate_items(self.PROJECT_NAME, 100, name="a" * 195) with self.assertRaisesRegex( AppException, diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index bf30b1385..864054c6b 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -53,8 +53,7 @@ def setUp(self, *args, **kwargs): ) team = sa.controller.team project = sa.controller.get_project(self.PROJECT_NAME) - # todo check - # time.sleep(10) + with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( team, project, template=json.load(f) @@ -87,12 +86,10 @@ def _base_test(self, path, item): def test_overwrite_false(self): # test root by folder name self._attach_item(self.PROJECT_NAME, "dummy") - # time.sleep(2) self._base_test(self.PROJECT_NAME, "dummy") folder = sa.create_folder(self.PROJECT_NAME, folder_name="folder") # test from folder by project and folder names - # time.sleep(2) path = f"{self.PROJECT_NAME}/folder" self._attach_item(path, "dummy") self._base_test(path, "dummy") @@ -105,7 +102,7 @@ def test_overwrite_false(self): self._base_test((self._project["id"], folder["id"]), "dummy") # test from folder by project and folder ids as tuple and item id - item = sa.search_items(f"{self.PROJECT_NAME}/folder", "dummy")[0] + item = sa.list_items(self.PROJECT_NAME, "folder", name="dummy")[0] self._base_test((self._project["id"], folder["id"]), item["id"]) def test_set_component_value_stamps_last_action(self): @@ -143,7 +140,6 @@ def setUp(self, *args, **kwargs): ) team = sa.controller.team project = sa.controller.get_project(self.PROJECT_NAME) - # time.sleep(10) with open(self.EDITOR_TEMPLATE_PATH) as f: res = sa.controller.service_provider.projects.attach_editor_template( team, project, template=json.load(f) diff --git a/tests/integration/items/test_move_items.py b/tests/integration/items/test_move_items.py index 00232eb4e..111f63739 100644 --- a/tests/integration/items/test_move_items.py +++ b/tests/integration/items/test_move_items.py @@ -48,7 +48,7 @@ def test_move_items_from_root(self): self.PROJECT_NAME, f"{self.PROJECT_NAME}/{self.FOLDER_1}" ) assert len(skipped_items) == 0 - assert len(sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}")) == 7 + assert len(sa.list_items(self.PROJECT_NAME, self.FOLDER_1)) == 7 def test_move_items_from_folder(self): sa.create_folder(self.PROJECT_NAME, self.FOLDER_1) @@ -67,8 +67,8 @@ def test_move_items_from_folder(self): f"{self.PROJECT_NAME}/{self.FOLDER_2}", ) assert len(skipped_items) == 0 - assert len(sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}")) == 2 - assert len(sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}")) == 0 + assert len(sa.list_items(self.PROJECT_NAME, self.FOLDER_2)) == 2 + assert len(sa.list_items(self.PROJECT_NAME, self.FOLDER_1)) == 0 with tempfile.TemporaryDirectory() as tmp_dir: sa.download_image_annotations( f"{self.PROJECT_NAME}/{self.FOLDER_2}", self.IMAGE_NAME, tmp_dir @@ -102,7 +102,7 @@ def test_move_items_from_folder_with_replace(self): f"{self.PROJECT_NAME}/{self.FOLDER_2}", self.ATTACHMENT ) assert len(uploaded_2) == 2 - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert folder_2_items[0]["annotation_status"] == "NotStarted" assert not folder_2_items[0]["approval_status"] @@ -112,12 +112,12 @@ def test_move_items_from_folder_with_replace(self): duplicate_strategy="replace", ) assert len(skipped_items) == 0 - folder_1_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_1_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_1) + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert len(folder_1_items) == 0 assert len(folder_2_items) == 2 - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert folder_2_items[0]["annotation_status"] == "Completed" assert folder_2_items[0]["approval_status"] == "Approved" @@ -147,7 +147,7 @@ def test_move_items_from_folder_with_replace_annotations_only(self): f"{self.PROJECT_NAME}/{self.FOLDER_2}", self.ATTACHMENT ) assert len(uploaded_2) == 2 - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert folder_2_items[0]["annotation_status"] == "NotStarted" assert not folder_2_items[0]["approval_status"] @@ -157,12 +157,12 @@ def test_move_items_from_folder_with_replace_annotations_only(self): duplicate_strategy="replace_annotations_only", ) assert len(skipped_items) == 0 - folder_1_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_1_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_1) + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert len(folder_1_items) == 0 assert len(folder_2_items) == 2 - folder_2_items = sa.search_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}") + folder_2_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_2) assert folder_2_items[0]["annotation_status"] == "NotStarted" assert not folder_2_items[0]["approval_status"] with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/integration/items/test_search_items.py b/tests/integration/items/test_search_items.py deleted file mode 100644 index ecd4720a9..000000000 --- a/tests/integration/items/test_search_items.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -from pathlib import Path - -from src.superannotate import SAClient -from tests.integration.base import BaseTestCase -from tests.integration.items import IMAGE_EXPECTED_KEYS - -sa = SAClient() - - -class TestSearchItems(BaseTestCase): - PROJECT_NAME = "TestSearchItems" - PROJECT_DESCRIPTION = "TestSearchItems" - PROJECT_TYPE = "Vector" - TEST_FOLDER_PATH = "data_set/sample_project_vector" - IMAGE1_NAME = "example_image_1.jpg" - IMAGE2_NAME = "example_image_2.jpg" - - @property - def folder_path(self): - return os.path.join(Path(__file__).parent.parent.parent, self.TEST_FOLDER_PATH) - - def test_search_items_multiple(self): - sa.attach_items( - self.PROJECT_NAME, [{"name": str(i), "url": str(i)} for i in range(2003)] - ) - items = sa.search_items(self.PROJECT_NAME) - assert len(items) == 2003 - - def test_search_items_metadata(self): - sa.upload_images_from_folder_to_project( - self.PROJECT_NAME, self.folder_path, annotation_status="InProgress" - ) - items = sa.search_items(self.PROJECT_NAME) - assert list(items[0].keys()).sort() == IMAGE_EXPECTED_KEYS.sort() - assert len(items) == 4 - assert ( - len(sa.search_items(self.PROJECT_NAME, qa_email="justaemail@google.com")) - == 0 - ) - assert ( - len( - sa.search_items( - self.PROJECT_NAME, annotator_email="justaemail@google.com" - ) - ) - == 0 - ) - assert len(sa.search_items(self.PROJECT_NAME, name_contains="1.jp")) == 1 - assert len(sa.search_items(self.PROJECT_NAME, name_contains=".jpg")) == 4 - assert len(sa.search_items(self.PROJECT_NAME, recursive=True)) == 4 - sa.set_annotation_statuses( - self.PROJECT_NAME, - "Completed", - [self.IMAGE1_NAME, self.IMAGE2_NAME], - ) - assert ( - len( - sa.search_items( - self.PROJECT_NAME, - annotation_status="Completed", - ) - ) - == 2 - ) - - def test_search_items_recursive(self): - sa.create_folder(self.PROJECT_NAME, "test") - sa.upload_images_from_folder_to_project( - self.PROJECT_NAME, self.folder_path, annotation_status="InProgress" - ) - sa.upload_images_from_folder_to_project( - self.PROJECT_NAME + "/test", - self.folder_path, - annotation_status="InProgress", - ) - - items = sa.search_items(self.PROJECT_NAME, recursive=True) - assert len(items) == 8 diff --git a/tests/integration/items/test_set_annotation_statuses.py b/tests/integration/items/test_set_annotation_statuses.py index 01dddb12c..fdafcbe62 100644 --- a/tests/integration/items/test_set_annotation_statuses.py +++ b/tests/integration/items/test_set_annotation_statuses.py @@ -46,7 +46,7 @@ def test_image_annotation_status(self): self.PROJECT_NAME, "QualityCheck", ) - for image in sa.search_items(self.PROJECT_NAME): + for image in sa.list_items(self.PROJECT_NAME): self.assertEqual(image["annotation_status"], "QualityCheck") def test_image_annotation_status_via_names(self): @@ -80,5 +80,5 @@ def test_set_annotation_statuses(self): annotation_status="Completed", items=[self.ATTACHMENT_LIST[0]["name"]], ) - data = sa.search_items(self.PROJECT_NAME)[0] + data = sa.list_items(self.PROJECT_NAME)[0] assert data["annotation_status"] == "Completed" diff --git a/tests/integration/items/test_set_approval_statuses.py b/tests/integration/items/test_set_approval_statuses.py index 153241465..b448b2a85 100644 --- a/tests/integration/items/test_set_approval_statuses.py +++ b/tests/integration/items/test_set_approval_statuses.py @@ -45,7 +45,7 @@ def test_image_approval_status(self): self.PROJECT_NAME, "Approved", ) - for image in sa.search_items(self.PROJECT_NAME): + for image in sa.list_items(self.PROJECT_NAME): self.assertEqual(image["approval_status"], "Approved") def test_image_approval_status_via_names(self): @@ -75,7 +75,7 @@ def test_set_approval_statuses(self): approval_status=None, items=[ATTACHMENT_LIST[0]["name"]], ) - data = sa.search_items(self.PROJECT_NAME)[0] + data = sa.list_items(self.PROJECT_NAME)[0] assert data["approval_status"] is None def test_set_invalid_approval_statuses(self): @@ -102,5 +102,5 @@ def test_item_approval_status(self): self.PROJECT_NAME, "Approved", ) - for item in sa.search_items(self.PROJECT_NAME): + for item in sa.list_items(self.PROJECT_NAME): self.assertEqual(item["approval_status"], "Approved") diff --git a/tests/integration/mixpanel/test_mixpanel_decorator.py b/tests/integration/mixpanel/test_mixpanel_decorator.py index 855bf3c4b..3212035c3 100644 --- a/tests/integration/mixpanel/test_mixpanel_decorator.py +++ b/tests/integration/mixpanel/test_mixpanel_decorator.py @@ -20,6 +20,7 @@ class TestMixpanel(TestCase): "SDK": True, "Team": sa.get_team_metadata()["name"], "User Email": sa.controller.current_user.email, + "Auth Type": sa.controller.token_context.auth_type, "Version": __version__, "Success": True, "Python version": platform.python_version(), @@ -60,7 +61,9 @@ def test_init(self, track_method): SAClient() result = list(track_method.call_args)[0] payload = self.default_payload - payload.update({"sa_token": "False", "config_path": "False"}) + # team_id is part of the SAClient signature, so it is tracked like every + # other argument (None unless the token needs an explicit team). + payload.update({"sa_token": "False", "config_path": "False", "team_id": None}) assert result[1] == "__init__" assert payload == result[2] @@ -75,6 +78,9 @@ def test_init_via_token(self, get_user, get_team_use_case, track_method): { "sa_token": "True", "config_path": "False", + "team_id": None, + # A legacy "=" token, whatever the ambient one is. + "Auth Type": "sdk", "Team": get_team_use_case().execute().data.name, "User Email": get_user().data.email, } @@ -101,6 +107,8 @@ def test_init_via_config_file(self, get_user, get_team_use_case, track_method): { "sa_token": "False", "config_path": "True", + "team_id": None, + "Auth Type": "sdk", "Team": get_team_use_case().execute().data.name, "User Email": get_user().data.email, } @@ -118,21 +126,6 @@ def test_get_team_metadata(self, track_method): assert result[1] == "get_team_metadata" assert payload == result[2] - @patch("lib.app.interface.base_interface.Tracker._track") - def test_search_team_contributors(self, track_method): - kwargs = { - "email": "user@supernnotate.com", - "first_name": "first_name", - "last_name": "last_name", - "return_metadata": False, - } - sa.search_team_contributors(**kwargs) - result = list(track_method.call_args)[0] - payload = self.default_payload - payload.update(kwargs) - assert result[1] == "search_team_contributors" - assert payload == result[2] - @patch("lib.app.interface.base_interface.Tracker._track") def test_search_projects(self, track_method): kwargs = { diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 55dc8ab7f..4d862767d 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -124,7 +124,7 @@ def test_upload_images(self): extensions="jpg", set_annotation_status="QualityCheck", ) - self.assertEqual(1, len(sa.search_items(self.PROJECT_NAME))) + self.assertEqual(1, len(sa.list_items(self.PROJECT_NAME))) def test_upload_export(self): self._create_project() @@ -160,7 +160,7 @@ def test_vector_annotation_folder_upload_download_cli(self): count_in = len(list(self.vector_folder_path.glob("*.json"))) with tempfile.TemporaryDirectory() as temp_dir: - for image in sa.search_items(self.PROJECT_NAME): + for image in sa.list_items(self.PROJECT_NAME): image_name = image["name"] sa.download_image_annotations(self.PROJECT_NAME, image_name, temp_dir) count_out = len(list(Path(temp_dir).glob("*.json"))) @@ -171,7 +171,7 @@ def test_attach_image_urls(self): self.safe_run( self._cli.attach_image_urls, self.PROJECT_NAME, str(self.video_csv_path) ) - self.assertEqual(3, len(sa.search_items(self.PROJECT_NAME))) + self.assertEqual(3, len(sa.list_items(self.PROJECT_NAME))) def test_attach_video_urls(self): self._create_project("Video") diff --git a/tests/integration/test_image_upload.py b/tests/integration/test_image_upload.py index 86f93453b..234740218 100644 --- a/tests/integration/test_image_upload.py +++ b/tests/integration/test_image_upload.py @@ -31,7 +31,7 @@ def test_single_image_upload(self): self.folder_path + "/example_image_1.jpg", annotation_status="InProgress", ) - assert len(sa.search_items(self.PROJECT_NAME)) == 1 + assert len(sa.list_items(self.PROJECT_NAME)) == 1 with open(self.folder_path + "/example_image_1.jpg", "rb") as f: img = io.BytesIO(f.read()) @@ -40,7 +40,7 @@ def test_single_image_upload(self): self.PROJECT_NAME, img, image_name="rr.jpg", annotation_status="InProgress" ) - assert len(sa.search_items(self.PROJECT_NAME)) == 2 + assert len(sa.list_items(self.PROJECT_NAME)) == 2 class TestMultipleImageUpload(BaseTestCase): diff --git a/tests/integration/test_recursive_folder.py b/tests/integration/test_recursive_folder.py index 657e278de..adcc5537a 100644 --- a/tests/integration/test_recursive_folder.py +++ b/tests/integration/test_recursive_folder.py @@ -44,7 +44,7 @@ def test_non_recursive_annotations_folder(self): annotation_status="QualityCheck", recursive_subfolders=True, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 2) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 2) sa.create_annotation_classes_from_classes_json( self.PROJECT_NAME, f"{self.folder_path}/classes/classes.json" @@ -74,7 +74,7 @@ def test_recursive_annotations_folder(self): recursive_subfolders=True, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 2) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 2) sa.create_annotation_classes_from_classes_json( self.PROJECT_NAME, f"{self.folder_path}/classes/classes.json" @@ -96,7 +96,7 @@ def test_recursive_annotations_folder_negative_case(self): recursive_subfolders=True, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 2) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 2) def test_annotations_recursive_s3_folder(self): @@ -107,7 +107,7 @@ def test_annotations_recursive_s3_folder(self): from_s3_bucket="superannotate-python-sdk-test", recursive_subfolders=True, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 2) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 2) sa.create_annotation_classes_from_classes_json( self.PROJECT_NAME, @@ -139,7 +139,7 @@ def test_annotations_non_recursive_s3_folder(self): recursive_subfolders=False, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 1) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 1) sa.create_annotation_classes_from_classes_json( self.PROJECT_NAME, @@ -175,7 +175,7 @@ def test_images_non_recursive_s3(self): recursive_subfolders=False, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 1) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 1) @pytest.mark.skip(reason="Taking long time.") def test_images_recursive_s3_122(self): @@ -185,7 +185,7 @@ def test_images_recursive_s3_122(self): from_s3_bucket="superannotate-python-sdk-test", recursive_subfolders=True, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 122) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 122) @pytest.mark.skip(reason="Taking long time.") def test_annotations_recursive_s3_122(self): @@ -222,4 +222,4 @@ def test_images_non_recursive(self): sa.upload_images_from_folder_to_project( self.PROJECT_NAME, self.folder_path, recursive_subfolders=False ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 1) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 1) diff --git a/tests/integration/test_video.py b/tests/integration/test_video.py index 6ff1e0bfa..7328f21fd 100644 --- a/tests/integration/test_video.py +++ b/tests/integration/test_video.py @@ -77,11 +77,7 @@ def test_video_upload_from_folder(self): self.folder_path, target_fps=1, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 5) - self.assertEqual( - len(sa.search_items(f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME}")), - len(sa.search_items(self.PROJECT_NAME)), - ) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 5) def test_single_video_upload(self): sa.upload_video_to_project( @@ -89,7 +85,7 @@ def test_single_video_upload(self): f"{self.folder_path}/{self.TEST_VIDEO_NAME}", target_fps=1, ) - self.assertEqual(len(sa.search_items(self.PROJECT_NAME)), 5) + self.assertEqual(len(sa.list_items(self.PROJECT_NAME)), 5) @pytest.fixture(autouse=True) def inject_fixtures(self, caplog): @@ -104,11 +100,7 @@ def test_video_big(self): target_fps=1, ) self.assertEqual( - len( - sa.search_items( - f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME_BIG_VIDEO}" - ) - ), + len(sa.list_items(self.PROJECT_NAME, self.TEST_FOLDER_NAME_BIG_VIDEO)), 31, ) sa.upload_video_to_project( diff --git a/tests/integration/work_management/test_team_admin_user_permissions.py b/tests/integration/work_management/test_team_admin_user_permissions.py new file mode 100644 index 000000000..6ee69a7ec --- /dev/null +++ b/tests/integration/work_management/test_team_admin_user_permissions.py @@ -0,0 +1,656 @@ +from unittest import skip +from unittest import skipIf +from unittest import skipUnless +from unittest import TestCase + +from lib.core import TEAM_USER_PERMISSION_DEPRECATED_IDS +from lib.core.exceptions import AppException +from src.superannotate import SAClient + +sa = SAClient() + +#: A team-scoped API key authenticates as the team itself - there is no user behind +#: it - so the backend does not let it change a team admin's permissions: the write +#: is accepted but nothing is applied, and the SDK reports the attempt as a failure. +#: A personal (team-user) API key and a legacy team-owner token both authenticate as +#: a user and may update team admin permissions, which is what the bulk of this +#: module asserts. The suite therefore picks its expectations from the token the +#: client was built with. +IS_TEAM_KEY = sa.controller.token_context.is_team_key +TEAM_KEY_ONLY = "requires a team-scoped API key" +USER_KEY_ONLY = "requires a personal (team-user) API key or a legacy token" +#: Reason line the SDK adds to every permission-update failure, and the only one +#: that applies when the token itself is what blocked the update. +INSUFFICIENT_KEY_REASON = ( + "The API key used does not have sufficient permissions to perform this action." +) + + +class TeamAdminPermissionsMixin: + """Permission names and read-only helpers shared by both token flavours.""" + + # "Access Orchestrate" (id 27) is apostrophe-free, so exact log assertions on + # it are stable regardless of the backend's curly/straight rendering. All + # admin permissions are reversible. + PERMISSION = "Access Orchestrate" + # Admin permission whose canonical name contains an apostrophe. + OTHER_PERMISSION = "Revoke members' personal API keys" + # The team-admin master (id 29): granting it grants every other admin + # permission, and no admin permission can be revoked while it is granted. + MASTER_PERMISSION = "Access team API keys" + # A contributor-only permission; granting it to an admin must be rejected. + CONTRIBUTOR_PERMISSION = "Invite Contributors to team" + + scapegoat: dict + + @classmethod + def _admin_permission_names(cls): + # The grantable team-admin permissions for this team, so the wildcard + # assertions and cleanup don't hardcode a count that changes whenever an + # admin permission is added or renamed. Deprecated ids come from the + # source constant so the test cannot drift from the implementation. + groups = sa.controller.service_provider.get_team_user_permission_groups() + for name, perms in groups.items(): + if "admin" in name.lower(): + return { + n + for pid, n in perms.items() + if pid not in TEAM_USER_PERMISSION_DEPRECATED_IDS + } + return set() + + @classmethod + def _confirmed_admins(cls): + return [ + u + for u in sa.list_users() + if u.get("state") == "Confirmed" + and u.get("role") in ("TeamAdmin", "TeamOwner") + ] + + @classmethod + def _user_permissions(cls): + return ( + sa.list_users(email=cls.scapegoat["email"])[0].get("user_permissions") or [] + ) + + @classmethod + def _granted(cls): + return {p["name"] for p in cls._user_permissions()} + + @classmethod + def _granted_ids(cls): + return {p["id"] for p in cls._user_permissions()} + + +@skipIf(IS_TEAM_KEY, USER_KEY_ONLY) +class TestTeamAdminUserPermissions(TeamAdminPermissionsMixin, TestCase): + """Team admin permission updates through a token that acts as a user.""" + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + cls.scapegoat = cls._find_admin() + # The scapegoat may be a real admin holding real permissions (there is + # not always a permission-free admin to borrow), so snapshot the exact + # ids and put the account back in tearDownClass. _find_admin guarantees + # every held permission can actually be written back. + cls.original_permission_ids = cls._granted_ids() + cls._cleanup() + + @classmethod + def tearDownClass(cls) -> None: + cls._restore() + + @classmethod + def _restore(cls): + # Restore by id through the declarative endpoint rather than by + # re-granting names: a held permission may no longer be grantable (e.g. + # "View SDK Token"), so a name-based grant could not put it back. + sa.controller.service_provider.work_management.set_team_user_permissions( + contributor_id=sa.list_users(email=cls.scapegoat["email"])[0]["id"], + permission_ids=list(cls.original_permission_ids), + ) + + @classmethod + def _find_admin(cls): + """Pick an admin whose permission state the suite can safely restore. + + The tests clear the borrowed account down to a known baseline, so it must + be possible to put every permission back afterwards. Permissions in + ``TEAM_USER_PERMISSION_DEPRECATED_IDS`` cannot be written at all (the + backend silently drops them even through the declarative endpoint), so + clearing an account that holds one is irreversible - never borrow it. + Prefer an admin with no permissions, then any whose set is restorable. + """ + candidates = [] + for u in cls._confirmed_admins(): + ids = { + p["id"] + for p in ( + sa.list_users(email=u["email"])[0].get("user_permissions") or [] + ) + } + if ids & TEAM_USER_PERMISSION_DEPRECATED_IDS: + continue + candidates.append((len(ids), u)) + if not candidates: + raise RuntimeError( + "No Confirmed team admin available whose permissions can be " + "restored after the test run. Admins holding " + f"{sorted(TEAM_USER_PERMISSION_DEPRECATED_IDS)} are skipped " + "because those permissions cannot be granted back." + ) + candidates.sort(key=lambda candidate: candidate[0]) + return candidates[0][1] + + @classmethod + def _cleanup(cls): + # revoke "*" clears the whole admin set in one call, master included. + # Revoking name-by-name would deadlock: while the master is granted no + # sibling can be revoked, so the outcome would depend on iteration order. + try: + sa.revoke_team_user_permissions( + permissions="*", + user=cls.scapegoat["email"], + ) + except Exception: + pass + + def tearDown(self): + self._cleanup() + + def test_grant_permission_by_email(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + self.assertEqual( + cm.output[0], + f"INFO:sa:Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + ) + + def test_grant_permission_by_user_id(self): + team_user_id = sa.list_users(email=self.scapegoat["email"])[0]["id"] + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.OTHER_PERMISSION], + user=team_user_id, + ) + self.assertEqual( + cm.output[0], + f"INFO:sa:Successfully granted [{self.OTHER_PERMISSION}] " + f"permission(s) for user: {self.scapegoat['email']}.", + ) + + def test_grant_curly_apostrophe_input_resolves(self): + # The canonical name uses a straight apostrophe; a curly one must still + # resolve and be reported back under the canonical name. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["Revoke members’ personal API keys"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Successfully granted [{self.OTHER_PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn(self.OTHER_PERMISSION, self._granted()) + + def test_grant_lowercase_input_resolves(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION.lower()], + user=self.scapegoat["email"], + ) + self.assertEqual( + cm.output[0], + f"INFO:sa:Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + ) + + def test_grant_all_permissions_wildcard(self): + # "*" resolves to every grantable permission of the admin role. Fully + # reversible, so it can be exercised idempotently. + expected = self._admin_permission_names() + self.assertTrue(expected, "no team admin permissions reported by the backend") + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions="*", + user=self.scapegoat["email"], + ) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully granted [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + line = success[0] + self.assertIn(f"permission(s) for user: {self.scapegoat['email']}.", line) + for name in expected: + self.assertIn(name, line) + # The deprecated permission is skipped, so there is no failure block. + self.assertFalse( + [o for o in cm.output if o.startswith("INFO:sa:Could not grant [")], + f"unexpected failure log: {cm.output}", + ) + self.assertEqual(self._granted(), expected) + + # ---- the team-admin master: "Access team API keys" ------------------- + + def test_grant_master_cascades_to_whole_admin_group(self): + # Granting the master must grant every other grantable admin permission. + expected = self._admin_permission_names() + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.MASTER_PERMISSION], + user=self.scapegoat["email"], + ) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully granted [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + for name in expected: + self.assertIn(name, success[0]) + # No spurious failure for the deprecated permission. + self.assertFalse( + [o for o in cm.output if o.startswith("INFO:sa:Could not grant [")], + f"unexpected failure log: {cm.output}", + ) + self.assertEqual(self._granted(), expected) + + def test_revoke_blocked_while_master_enabled(self): + # While the master is granted it implies every admin permission, so an + # individual one cannot be revoked; the SDK reports the admin-master + # failure and the state is left untouched. + email = self.scapegoat["email"] + sa.grant_team_user_permissions(permissions=[self.MASTER_PERMISSION], user=email) + before = self._granted() + self.assertIn( + self.MASTER_PERMISSION, before, "setup failed: master not granted" + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions(permissions=[self.PERMISSION], user=email) + failure = [o for o in cm.output if o.startswith("INFO:sa:Could not revoke [")] + self.assertTrue(failure, f"expected failure log, got {cm.output}") + joined = "\n".join(failure) + self.assertIn( + f"If {self.MASTER_PERMISSION} is granted, it must be revoked before " + f"[{self.PERMISSION}] can be revoked for this user.", + joined, + ) + # The hint stays scoped to the admin group. + self.assertNotIn("Manage Contributors", joined) + # Nothing was revoked. + self.assertEqual(self._granted(), before) + + def test_revoke_master_leaves_siblings_granted(self): + # The master itself is revocable; the permissions it implied remain. + email = self.scapegoat["email"] + sa.grant_team_user_permissions(permissions=[self.MASTER_PERMISSION], user=email) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=[self.MASTER_PERMISSION], user=email + ) + self.assertEqual( + cm.output[0], + f"INFO:sa:Successfully revoked [{self.MASTER_PERMISSION}] " + f"permission(s) for user: {email}.", + ) + remaining = self._granted() + self.assertNotIn(self.MASTER_PERMISSION, remaining) + self.assertEqual( + remaining, self._admin_permission_names() - {self.MASTER_PERMISSION} + ) + + def test_revoke_wildcard_clears_master_and_siblings(self): + email = self.scapegoat["email"] + sa.grant_team_user_permissions(permissions=[self.MASTER_PERMISSION], user=email) + self.assertTrue(self._granted(), "setup failed: nothing granted") + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions(permissions="*", user=email) + self.assertTrue( + [o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [")], + f"expected success log, got {cm.output}", + ) + self.assertEqual(self._granted(), set()) + + def test_grant_already_granted_logs_failure(self): + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not grant [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn( + f"User already has [{self.PERMISSION}] permission(s) granted.", + joined, + ) + # The token is never the reason here, but it is one of the listed + # possibilities on any failed grant. + self.assertIn(INSUFFICIENT_KEY_REASON, joined) + + def test_revoke_permission(self): + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + self.assertEqual( + cm.output[0], + f"INFO:sa:Successfully revoked [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + ) + + def test_revoke_all_permissions_wildcard(self): + # revoke "*" clears every admin permission the user currently holds. + # Admin permissions are fully reversible, so this is idempotent. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=[self.PERMISSION, self.OTHER_PERMISSION], + user=email, + ) + granted = { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + self.assertEqual(granted, {self.PERMISSION, self.OTHER_PERMISSION}) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions(permissions="*", user=email) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + remaining = { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + self.assertEqual(remaining, set()) + + def test_revoke_already_revoked_logs_failure(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not revoke [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn( + f"[{self.PERMISSION}] permission(s) were already revoked for the user.", + joined, + ) + self.assertIn(INSUFFICIENT_KEY_REASON, joined) + + def test_grant_invalid_permission_logs_failure(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn("Provided permission(s) were invalid.", joined) + + def test_grant_mixed_valid_and_invalid_logs_both(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION, "NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn("Provided permission(s) were invalid.", joined) + + def test_grant_contributor_permission_for_admin_logs_failure(self): + # Contributor-only permissions must not be grantable to an admin; the + # backend rejects the batch and the SDK reports a role-mismatch failure. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.CONTRIBUTOR_PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not grant [{self.CONTRIBUTOR_PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn( + f"User role does not allow [{self.CONTRIBUTOR_PERMISSION}] " + f"permission(s).", + joined, + ) + # Sanity: the contributor permission was not actually granted. + granted = { + p["name"] + for p in ( + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") + or [] + ) + } + self.assertNotIn(self.CONTRIBUTOR_PERMISSION, granted) + + def test_grant_empty_permissions_raises(self): + with self.assertRaisesRegex(AppException, r"Permission\(s\) cannot be empty\."): + sa.grant_team_user_permissions( + permissions=[], + user=self.scapegoat["email"], + ) + + def test_revoke_empty_permissions_raises(self): + with self.assertRaisesRegex(AppException, r"Permission\(s\) cannot be empty\."): + sa.revoke_team_user_permissions( + permissions=[], + user=self.scapegoat["email"], + ) + + def test_grant_unknown_user_raises(self): + with self.assertRaisesRegex(AppException, "User not found."): + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user="non_existent_admin@superannotate.com", + ) + + def test_revoke_unknown_user_raises(self): + with self.assertRaisesRegex(AppException, "User not found."): + sa.revoke_team_user_permissions( + permissions=[self.PERMISSION], + user="non_existent_admin@superannotate.com", + ) + + +@skipUnless(IS_TEAM_KEY, TEAM_KEY_ONLY) +class TestTeamAdminUserPermissionsWithTeamKey(TeamAdminPermissionsMixin, TestCase): + """Team admin permission updates through a key that acts as the team. + + Only granting is refused: the backend accepts the write, applies nothing, and + the SDK reports a failure whose possible reasons include the insufficient key. + + Revoking is *not* refused - a team key really does remove the permission (see + ``test_revoke_of_a_held_permission_is_not_blocked``). That asymmetry makes any + successful revoke a one-way door for this suite: the same key cannot grant the + permission back afterwards, so nothing here revokes a permission the account + actually holds. Every case below leaves the account untouched, which is what + lets the class run without a restore step. + """ + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + admins = cls._confirmed_admins() + if not admins: + raise RuntimeError("No Confirmed team admin available to test against.") + cls.scapegoat = admins[0] + + def _assert_denied(self, operation: str, permissions, expected_names): + """Run an update that the key is not allowed to make and check the log.""" + email = self.scapegoat["email"] + before = self._granted() + update = ( + sa.grant_team_user_permissions + if operation == "grant" + else sa.revoke_team_user_permissions + ) + past = "granted" if operation == "grant" else "revoked" + with self.assertLogs("sa", level="INFO") as cm: + update(permissions=permissions, user=email) + self.assertFalse( + [o for o in cm.output if o.startswith(f"INFO:sa:Successfully {past} [")], + f"nothing should have been {past} here, got {cm.output}", + ) + failure = [ + o for o in cm.output if o.startswith(f"INFO:sa:Could not {operation} [") + ] + self.assertTrue(failure, f"expected failure log, got {cm.output}") + joined = "\n".join(failure) + self.assertIn(f"permission(s) for user: {email}.", joined) + for name in expected_names: + self.assertIn(name, joined) + self.assertIn(INSUFFICIENT_KEY_REASON, joined) + # The permission set is untouched. + self.assertEqual(self._granted(), before) + + def _unheld_admin_permission(self): + """An admin permission the account does not have, so the grant is real. + + Granting one it already holds would fail for a second reason ("already + granted"), which would not prove the key was refused. The master is + excluded as well: it cascades to the whole group, so the failure would be + reported for every admin permission rather than for the requested one. + """ + missing = self._admin_permission_names() - self._granted() + missing -= {self.MASTER_PERMISSION} + if not missing: + self.skipTest( + "the borrowed admin holds every non-master admin permission, so " + "there is no grant left to be refused" + ) + # Prefer the module's reference permission when it is available. + return self.PERMISSION if self.PERMISSION in missing else sorted(missing)[0] + + def test_grant_permission_denied(self): + permission = self._unheld_admin_permission() + self._assert_denied("grant", [permission], [permission]) + + def test_grant_by_user_id_denied(self): + permission = self._unheld_admin_permission() + team_user_id = sa.list_users(email=self.scapegoat["email"])[0]["id"] + before = self._granted() + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions(permissions=[permission], user=team_user_id) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not grant [{permission}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn(INSUFFICIENT_KEY_REASON, joined) + self.assertEqual(self._granted(), before) + + def test_grant_master_denied(self): + # The master cascades to the whole admin group, so the failure names every + # grantable admin permission: nothing changed, so nothing succeeded. + self._assert_denied( + "grant", [self.MASTER_PERMISSION], self._admin_permission_names() + ) + + def test_grant_wildcard_denied(self): + self._assert_denied("grant", "*", self._admin_permission_names()) + + @skip( + "A team key can revoke: the backend applies it, and the same key cannot " + "grant the permission back, so running this strips the borrowed admin for " + "good. Unskip only against an account whose permissions are disposable." + ) + def test_revoke_of_a_held_permission_is_not_blocked(self): + # Documents the asymmetry rather than asserting the denial: granting is + # refused for a team key, revoking is not. + held = self._granted() & self._admin_permission_names() + if not held: + self.skipTest("the borrowed admin holds no revocable permission") + permission = sorted(held)[0] + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=[permission], user=self.scapegoat["email"] + ) + self.assertTrue( + [o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [")], + f"expected the revoke to go through, got {cm.output}", + ) + self.assertNotIn(permission, self._granted()) + + def test_revoke_of_a_permission_not_held_reports_failure(self): + # Safe to run: nothing is revoked, so nothing has to be granted back. The + # key is one of the reasons offered for the failure. + missing = self._admin_permission_names() - self._granted() + if not missing: + self.skipTest("the borrowed admin holds every admin permission") + permission = sorted(missing)[0] + self._assert_denied("revoke", [permission], [permission]) + + def test_invalid_permission_still_reported_as_invalid(self): + # Name resolution happens client-side, so it is unaffected by the token. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn("Provided permission(s) were invalid.", joined) + + def test_empty_permissions_raises(self): + # Client-side validation, so it behaves the same for every token. + for update in ( + sa.grant_team_user_permissions, + sa.revoke_team_user_permissions, + ): + with self.assertRaisesRegex( + AppException, r"Permission\(s\) cannot be empty\." + ): + update(permissions=[], user=self.scapegoat["email"]) + + def test_unknown_user_raises(self): + for update in ( + sa.grant_team_user_permissions, + sa.revoke_team_user_permissions, + ): + with self.assertRaisesRegex(AppException, "User not found."): + update( + permissions=[self.PERMISSION], + user="non_existent_admin@superannotate.com", + ) diff --git a/tests/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py new file mode 100644 index 000000000..1ede2cb5c --- /dev/null +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -0,0 +1,599 @@ +from unittest import TestCase + +from lib.core.exceptions import AppException +from src.superannotate import SAClient + +sa = SAClient() + + +class TestTeamUserPermissions(TestCase): + # Apostrophe-free contributor permission so exact log assertions are stable + # regardless of the backend's curly/straight apostrophe rendering. + PERMISSION = "Invite Contributors to team" + # Contributor permission whose canonical name uses a curly apostrophe. + CURLY_PERMISSION = "View Contributors’ scores" + # An admin-only permission; granting it to a contributor must be rejected. + ADMIN_PERMISSION = "Access team API keys" + # Reversible cascade pair (no master involved): granting Edit auto-grants + # View, revoking View auto-revokes Edit. Straight apostrophes here; the SDK + # normalizes them to match the backend's canonical (curly) names. + EDIT_CUSTOM_FIELDS = "Edit Contributors' custom field values" + VIEW_CUSTOM_FIELDS = "View Contributors' custom field values" + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + # Scapegoat for the per-permission tests: any contributor without the + # (irreversible) "Manage Contributors' permissions" master. We don't + # need a pre-clean user — _reset() normalizes the chosen user to a + # known baseline by granting/revoking the required permissions, and + # teardown restores it. Prefer a Confirmed contributor; fall back to + # any state (e.g. Pending), since individual permissions can still be + # granted and revoked on them. + cls.scapegoat = cls._find_contributor_without_master() + if cls.scapegoat is None: + cls.scapegoat = cls._find_contributor_without_master( + require_confirmed=False + ) + if cls.scapegoat is None: + raise RuntimeError( + "No contributor without 'Manage Contributors' permissions " + "available for team-user permission tests." + ) + cls._reset(cls.scapegoat["email"]) + + @classmethod + def tearDownClass(cls) -> None: + cls._reset(cls.scapegoat["email"]) + + @classmethod + def _reset(cls, email): + # Reset a team user to no permissions via the declarative + # setpermissions endpoint. Unlike the old delta endpoint this also + # clears the "Manage Contributors' permissions" master, so the master / + # wildcard tests are fully reversible and need no disposable user. + contributor_id = sa.list_users(email=email)[0]["id"] + sa.controller.service_provider.work_management.set_team_user_permissions( + contributor_id=contributor_id, + permission_ids=[], + ) + + def tearDown(self): + self._reset(self.scapegoat["email"]) + + @staticmethod + def _has_master(perms): + return any("Manage Contributors" in (p.get("name") or "") for p in perms) + + @staticmethod + def _contributor_permission_names(): + # The full set of contributor permissions that actually exist for this + # team (id 25 may be absent depending on configuration), used to assert + # the master/wildcard cascade without hardcoding the count. + groups = sa.controller.service_provider.get_team_user_permission_groups() + for name, perms in groups.items(): + if "contributor" in name.lower(): + return set(perms.values()) + return set() + + @classmethod + def _find_contributor_without_master( + cls, exclude_email=None, require_confirmed=True + ): + for u in sa.list_users(): + if u.get("role") != "Contributor": + continue + if require_confirmed and u.get("state") != "Confirmed": + continue + if u.get("email") == exclude_email: + continue + full = sa.list_users(email=u["email"])[0] + if not cls._has_master(full.get("user_permissions") or []): + return u + return None + + @staticmethod + def _permission_names(email): + # Read the user's currently-granted team permissions from the live + # list_users response. This is the source of truth for whether a + # grant/revoke actually took effect on the backend. + return { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + + @classmethod + def _includes(cls, names, *fragments): + # True if some granted permission name contains every fragment. Uses + # fragment matching so assertions are robust to the backend rendering + # names with a curly apostrophe. + return any(all(f in n for f in fragments) for n in names) + + def _check_permissions_granted(self, user_email, permission: str): + user = sa.list_users(email=user_email)[0] + user_permissions = [i["name"] for i in user.get("user_permissions")] + assert permission in user_permissions + + def test_grant_permission_by_email(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + assert ( + f"INFO:sa:Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." == cm.output[0] + ) + self._check_permissions_granted(self.scapegoat["email"], self.PERMISSION) + + def test_grant_permission_by_user_id(self): + team_user_id = sa.list_users(email=self.scapegoat["email"])[0]["id"] + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=team_user_id, + ) + assert ( + f"INFO:sa:Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." == cm.output[0] + ) + self._check_permissions_granted(self.scapegoat["email"], self.PERMISSION) + + def test_grant_all_permissions_wildcard(self): + # "*" grants every permission available for the contributor role, + # including the "Manage Contributors' permissions" master. With the + # declarative setpermissions endpoint this is reversible, so it runs on + # the shared scapegoat and is cleaned up by _reset(). + email = self.scapegoat["email"] + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions(permissions="*", user=email) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully granted [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + line = success[0] + # Derived from the live group rather than a literal list: id 25 + # ("Access Workload management") is absent on some team configurations. + for key in self._contributor_permission_names(): + self.assertIn(key, line) + granted = self._permission_names(email) + self.assertEqual(granted, self._contributor_permission_names()) + self.assertTrue(self._has_master([{"name": n} for n in granted])) + + def test_grant_already_granted_logs_failure(self): + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not grant [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert "Possible reasons:" in joined + assert ( + f"User already has [{self.PERMISSION}] permission(s) granted." in joined + ) + + def test_revoke_permission(self): + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + assert ( + f"INFO:sa:Successfully revoked [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." == cm.output[0] + ) + + def test_revoke_all_permissions_wildcard(self): + # revoke "*" clears every permission the user currently holds. The + # scapegoat never holds the (irreversible) master, so this is fully + # reversible and runs idempotently on the shared scapegoat. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=[self.PERMISSION, self.CURLY_PERMISSION], + user=email, + ) + granted = { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + self.assertTrue(granted, "setup failed: permissions were not granted") + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions(permissions="*", user=email) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + remaining = { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + self.assertEqual(remaining, set()) + + def test_revoke_already_revoked_logs_failure(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not revoke [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert ( + f"[{self.PERMISSION}] permission(s) were already revoked for the user." + in joined + ) + + def test_grant_invalid_permission_logs_failure(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert "Provided permission(s) were invalid." in joined + + def test_grant_admin_permission_for_contributor_logs_failure(self): + # Admin-only permissions must not be grantable to a contributor; the + # backend rejects the batch and the SDK reports a role-mismatch failure + # with the full "Possible reasons" block. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.ADMIN_PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Could not grant [{self.ADMIN_PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", + joined, + ) + self.assertIn( + f"User role does not allow [{self.ADMIN_PERMISSION}] " + f"permission(s).", + joined, + ) + # Sanity: the admin permission was not actually granted. + granted = { + p["name"] + for p in ( + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") + or [] + ) + } + self.assertNotIn(self.ADMIN_PERMISSION, granted) + + def test_grant_mixed_valid_and_invalid_logs_both(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.PERMISSION, "NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert ( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert "Provided permission(s) were invalid." in joined + + def test_grant_apostrophe_normalization(self): + # The backend stores the canonical name with a curly apostrophe, but + # users should be able to grant using a straight apostrophe too. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["View Contributors' scores"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Successfully granted [{self.CURLY_PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + + def test_grant_empty_permissions_raises(self): + with self.assertRaisesRegex(AppException, r"Permission\(s\) cannot be empty\."): + sa.grant_team_user_permissions( + permissions=[], + user=self.scapegoat["email"], + ) + + def test_revoke_empty_permissions_raises(self): + with self.assertRaisesRegex(AppException, r"Permission\(s\) cannot be empty\."): + sa.revoke_team_user_permissions( + permissions=[], + user=self.scapegoat["email"], + ) + + def test_grant_unknown_user_raises(self): + with self.assertRaisesRegex(AppException, "User not found."): + sa.grant_team_user_permissions( + permissions=[self.PERMISSION], + user="non_existent_user@superannotate.com", + ) + + def test_revoke_unknown_user_raises(self): + with self.assertRaisesRegex(AppException, "User not found."): + sa.revoke_team_user_permissions( + permissions=[self.PERMISSION], + user="non_existent_user@superannotate.com", + ) + + def test_grant_manage_contributors_permissions_cascade(self): + # Granting "Manage Contributors' permissions" must cascade to all + # contributor permissions. Reversible via setpermissions, so it runs on + # the shared scapegoat and is cleaned up by _reset(). + email = self.scapegoat["email"] + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], + user=email, + ) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully granted [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + line = success[0] + # Derived from the live group rather than a literal list: id 25 + # ("Access Workload management") is absent on some team configurations. + for key in self._contributor_permission_names(): + self.assertIn(key, line) + self.assertEqual( + self._permission_names(email), self._contributor_permission_names() + ) + + def test_grant_master_when_others_already_granted_log(self): + # Only the master was asked for. Its group members already being granted + # is the normal case for the cascade, so nothing may be reported as a + # failure - and the already-granted ones must not be claimed as newly + # granted either. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=[self.PERMISSION, self.CURLY_PERMISSION], user=email + ) + self.assertEqual( + self._permission_names(email), {self.PERMISSION, self.CURLY_PERMISSION} + ) + + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], user=email + ) + joined = "\n".join(cm.output) + self.assertNotIn("Could not grant", joined) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully granted [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + line = success[0] + self.assertIn("Manage Contributors", line) + # Permissions the user already held did not change, so they are not + # listed as newly granted. + self.assertNotIn(self.PERMISSION, line) + self.assertNotIn(self.CURLY_PERMISSION, line) + # The master still pulled the whole contributor group in. + self.assertEqual( + self._permission_names(email), self._contributor_permission_names() + ) + + def test_revoke_master_permission(self): + # The master is now removable: revoking it drops the master while the + # other contributor permissions it implied remain granted. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], user=email + ) + self.assertTrue( + self._has_master([{"name": n} for n in self._permission_names(email)]) + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=["Manage Contributors' permissions"], user=email + ) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + names = self._permission_names(email) + self.assertFalse( + self._has_master([{"name": n} for n in names]), + f"master should be revoked, got {names}", + ) + # Other members that the master implied remain granted. + self.assertTrue( + self._includes(names, "Invite Contributors to team"), + f"members should remain after revoking the master, got {names}", + ) + + def test_revoke_blocked_while_manage_enabled(self): + # While "Manage Contributors' permissions" is enabled it implies every + # member, so an individual member cannot be revoked; the SDK reports the + # "revoke Manage Contributors' permissions first" failure. Reversible, + # so it runs on the scapegoat. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], user=email + ) + self.assertTrue( + self._has_master([{"name": n} for n in self._permission_names(email)]), + "setup failed: master permission was not granted", + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=["Remove Contributors from team"], + user=email, + ) + failure = [o for o in cm.output if o.startswith("INFO:sa:Could not revoke [")] + self.assertTrue(failure, f"expected failure log, got {cm.output}") + joined = "\n".join(failure) + self.assertIn("Remove Contributors from team", joined) + # The master name is the canonical one from the live permission groups, + # so it carries the backend's curly apostrophe; match around it. + self.assertIn("Manage Contributors", joined) + self.assertIn(" permissions is granted, it must be revoked before", joined) + # The hint stays scoped to the contributor group. + self.assertNotIn("Access team API keys", joined) + # The member is still present (revoke was blocked). + self.assertTrue( + self._includes( + self._permission_names(email), "Remove Contributors from team" + ) + ) + + def test_revoke_view_custom_field_values_cascade(self): + # Revoking "View Contributors' custom field values" must also revoke + # "Edit Contributors' custom field values". Granting "Edit" first + # also exercises the grant cascade (Edit auto-grants View). Both + # cascades are reversible (no master permission involved), so this + # runs on the clean scapegoat. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=["Edit Contributors' custom field values"], + user=self.scapegoat["email"], + ) + granted = { + p["name"] + for p in ( + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") + or [] + ) + } + self.assertTrue( + any( + "View Contributors" in n and "custom field values" in n for n in granted + ), + f"grant cascade should have granted View, got {granted}", + ) + self.assertTrue( + any("Edit Contributors" in n for n in granted), + f"grant should have granted Edit, got {granted}", + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_team_user_permissions( + permissions=["View Contributors' custom field values"], + user=self.scapegoat["email"], + ) + success = [ + o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [") + ] + self.assertTrue(success, f"expected success log, got {cm.output}") + joined = "\n".join(success) + self.assertIn("View Contributors", joined) + self.assertIn("Edit Contributors", joined) + remaining = { + p["name"] + for p in ( + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") + or [] + ) + } + self.assertFalse( + any("custom field values" in n for n in remaining), + f"expected both custom-field-value permissions revoked, got {remaining}", + ) + + # ---- actual permission state verified via list_users ---------------- + + def test_grant_actually_sets_permission(self): + # Grant must actually set the permission on the backend, confirmed by + # reading user_permissions back from list_users (not just the log). + email = self.scapegoat["email"] + self.assertNotIn( + self.PERMISSION, + self._permission_names(email), + "precondition: permission must not be set before grant", + ) + sa.grant_team_user_permissions(permissions=[self.PERMISSION], user=email) + self.assertIn( + self.PERMISSION, + self._permission_names(email), + "grant did not actually set the permission", + ) + + def test_revoke_actually_unsets_permission(self): + # Revoke must actually clear the permission on the backend, confirmed + # by reading user_permissions back from list_users. + email = self.scapegoat["email"] + sa.grant_team_user_permissions(permissions=[self.PERMISSION], user=email) + self.assertIn( + self.PERMISSION, + self._permission_names(email), + "setup failed: permission was not granted", + ) + sa.revoke_team_user_permissions(permissions=[self.PERMISSION], user=email) + self.assertNotIn( + self.PERMISSION, + self._permission_names(email), + "revoke did not actually unset the permission", + ) + + def test_grant_edit_custom_fields_grant_cascade_sets_view(self): + # Granting "Edit ... custom field values" must also set "View ... + # custom field values" (grant cascade), verified against the live + # user_permissions state, not just the success log. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=[self.EDIT_CUSTOM_FIELDS], user=email + ) + names = self._permission_names(email) + self.assertTrue( + self._includes(names, "Edit Contributors", "custom field values"), + f"Edit permission not set, got {names}", + ) + self.assertTrue( + self._includes(names, "View Contributors", "custom field values"), + f"grant cascade did not set View, got {names}", + ) + + def test_grant_edit_when_view_already_granted_leaves_both_set(self): + # When the grant cascade's dependent ("View ... custom field values") + # is already granted, granting "Edit ... custom field values" must + # still leave both set: the already-granted dependent is a no-op, not + # a failure that unsets state. Verified via live user_permissions. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=[self.VIEW_CUSTOM_FIELDS], user=email + ) + self.assertTrue( + self._includes( + self._permission_names(email), + "View Contributors", + "custom field values", + ), + "setup failed: View was not granted", + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.EDIT_CUSTOM_FIELDS], user=email + ) + # Only Edit was asked for; View being already granted is the normal + # case for the cascade, so it must not be reported as a failure. + self.assertNotIn("Could not grant", "\n".join(cm.output)) + names = self._permission_names(email) + self.assertTrue( + self._includes(names, "Edit Contributors", "custom field values"), + f"Edit permission not set, got {names}", + ) + self.assertTrue( + self._includes(names, "View Contributors", "custom field values"), + f"View should remain set after granting Edit, got {names}", + ) diff --git a/tests/unit/test_http_client.py b/tests/unit/test_http_client.py index 209cb01c4..d6edb3337 100644 --- a/tests/unit/test_http_client.py +++ b/tests/unit/test_http_client.py @@ -14,7 +14,7 @@ def setUp(self): @patch.dict(os.environ, {"sa_version": "1.0.0", "SA_ENV": "test"}) def test_default_headers_with_env(self): - client = HttpClient(self.api_url, self.token) + client = HttpClient(self.api_url, self.token, self.team_id) headers = client.default_headers expected_user_agent = ( @@ -27,9 +27,20 @@ def test_default_headers_with_env(self): assert headers["Content-Type"] == "application/json" assert headers["User-Agent"] == expected_user_agent + @patch.dict(os.environ, {"sa_version": "1.0.0"}) + def test_default_headers_auth_type(self): + client = HttpClient( + self.api_url, "sa_public_id_secret", self.team_id, auth_type="api_key" + ) + headers = client.default_headers + + assert headers["Authorization"] == "sa_public_id_secret" + assert headers["authtype"] == "api_key" + assert f"Team: {self.team_id}" in headers["User-Agent"] + @patch.dict(os.environ, {"sa_version": "2.0.0"}, clear=True) def test_default_headers_without_env(self): - client = HttpClient(self.api_url, self.token) + client = HttpClient(self.api_url, self.token, self.team_id) headers = client.default_headers expected_user_agent = ( @@ -42,7 +53,7 @@ def test_default_headers_without_env(self): def test_default_headers_no_version(self): with patch.dict(os.environ, {}, clear=True): - client = HttpClient(self.api_url, self.token) + client = HttpClient(self.api_url, self.token, self.team_id) headers = client.default_headers expected_user_agent = ( diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 7b1a8aeca..c3f8b01b8 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -2,8 +2,10 @@ import os import tempfile from configparser import ConfigParser +from copy import deepcopy from pathlib import Path from unittest import TestCase +from unittest.mock import MagicMock from unittest.mock import patch import superannotate.lib.core as constants @@ -164,3 +166,193 @@ def test_invalid_config_path(self): AppException, f"SuperAnnotate config file {_path} not found." ): SAClient(config_path=_path) + + +def _mock_response(payload: dict, ok: bool = True, status_code: int = 200): + response = MagicMock() + response.ok = ok + response.status_code = status_code + response.text = json.dumps(payload) + response.json.return_value = payload + return response + + +TEAM_TOKEN_RESPONSE = { + "user": None, + "token": { + "id": 1794, + "scope_id": "6085", + "status": "ACTIVE", + "scope": {"team_id": 6085}, + "created_by": "vaghinak@superannotate.com", + "name": "test 12", + "public_id": "UBDC6K2KiSrshky1", + "scope_type": "team", + }, +} + +TEAM_USER_TOKEN_RESPONSE = { + "user": { + "id": "vaghinak@superannotate.com", + "first_name": "Vaghinak", + "last_name": "Basentsyan", + "email": "vaghinak@superannotate.com", + }, + "token": { + "id": 1795, + "scope_id": "vaghinak@superannotate.com", + "parent_scope_id": "6085", + "scope": {"user_id": "vaghinak@superannotate.com", "team_id": 6085}, + "created_by": "vaghinak@superannotate.com", + "scope_type": "teamuser", + "status": "ACTIVE", + }, +} + +ORGANIZATION_TOKEN_RESPONSE = { + "user": None, + "token": { + "id": 1796, + "scope_id": "org-1", + "scope": {"organization_id": "org-1"}, + "created_by": "vaghinak@superannotate.com", + "scope_type": "organization", + "status": "ACTIVE", + }, +} + + +@patch("lib.infrastructure.controller.Controller.get_team") +@patch("lib.infrastructure.services.auth.requests.post") +class ApiKeyInitTestCase(TestCase): + _token = "sa_SOZVLlnbheUITTGb_PXlk2ON5QtqNPWY9bHZJctzlx4EPTkImzncQgRmybgh" + + def test_init_via_team_token(self, post, get_team): + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + sa = SAClient(token=self._token) + + assert sa.controller.team_id == 6085 + # A team-scoped token has no user behind it, so it falls back to its creator. + assert sa.controller.current_user.email == "vaghinak@superannotate.com" + # The team id comes from the token, but telemetry reports the team *name*, + # so init resolves the team once and caches it. + assert get_team.call_count == 1 + + client = sa.controller.service_provider.client + assert client.team_id == 6085 + assert client.auth_type == "api_key" + assert client.default_headers["authtype"] == "api_key" + assert client.default_headers["Authorization"] == self._token + + # The scope is kept: a team key acts as the team, so it is not allowed to + # perform user-level operations (updating a team admin's permissions). + context = sa.controller.token_context + assert context.scope_type == "team" + assert context.is_team_key + assert not context.is_personal_key + assert not context.is_legacy + + def test_token_context_request(self, post, get_team): + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + SAClient(token=self._token) + + assert post.call_count == 1 + url = post.call_args.args[0] + assert url.endswith("/users/me") + assert post.call_args.kwargs["json"] == {} + headers = post.call_args.kwargs["headers"] + assert headers["authtype"] == "api_key" + assert headers["Authorization"] == self._token + + def test_team_is_fetched_once(self, post, get_team): + # The token carries the team id, so the team itself is fetched only when + # something needs its data - the telemetry team name on init, here - and + # every later reader is served from the cache. + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + get_team.return_value.data = MagicMock(owner_id="org-1") + sa = SAClient(token=self._token) + + assert get_team.call_count == 1 + assert sa.controller.org_id == "org-1" + assert sa.controller.team.owner_id == "org-1" + assert get_team.call_count == 1 + + def test_init_via_team_user_token(self, post, get_team): + post.return_value = _mock_response(TEAM_USER_TOKEN_RESPONSE) + sa = SAClient(token=self._token) + + assert sa.controller.team_id == 6085 + assert sa.controller.current_user.email == "vaghinak@superannotate.com" + assert sa.controller.current_user.first_name == "Vaghinak" + + # A personal key acts as its user, so it may do what that user may do. + context = sa.controller.token_context + assert context.scope_type == "teamuser" + assert context.is_personal_key + assert not context.is_team_key + + def test_nested_service_clients_share_team_context(self, post, get_team): + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + sa = SAClient(token=self._token) + + for service in ( + sa.controller.service_provider.work_management, + sa.controller.service_provider.item_service, + ): + assert service.client.team_id == 6085 + assert service.client.auth_type == "api_key" + + def test_organization_api_key_rejected(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with self.assertRaisesRegex( + AppException, r"does not accept an Organization API key" + ): + SAClient(token=self._token) + + def test_unknown_scope_type_rejected(self, post, get_team): + response = deepcopy(TEAM_TOKEN_RESPONSE) + response["token"]["scope_type"] = "something-new" + post.return_value = _mock_response(response) + with self.assertRaisesRegex( + AppException, r"does not accept an Organization API key" + ): + SAClient(token=self._token) + + def test_team_scope_without_team_id_rejected(self, post, get_team): + # A malformed team-scoped response must not resolve to a team-less client. + response = deepcopy(TEAM_TOKEN_RESPONSE) + response["token"]["scope"] = {} + post.return_value = _mock_response(response) + with self.assertRaisesRegex( + AppException, r"does not accept an Organization API key" + ): + SAClient(token=self._token) + + def test_authentication_failure(self, post, get_team): + post.return_value = _mock_response({}, ok=False, status_code=401) + with self.assertRaisesRegex(AppException, r"Unable to authenticate"): + SAClient(token=self._token) + + +class LegacyTokenTestCase(TestCase): + @patch("lib.infrastructure.controller.Controller.get_current_user") + @patch("lib.infrastructure.controller.Controller.get_team") + @patch("lib.infrastructure.services.auth.requests.post") + def test_legacy_token_resolves_offline(self, post, get_team, get_current_user): + sa = SAClient(token="token=123") + + assert post.call_count == 0 + assert sa.controller.team_id == 123 + assert sa.controller.service_provider.client.auth_type == "sdk" + # No scope is reported for a legacy token, and it is not a team key: it + # acts as the team owner, so it may update team admin permissions. + context = sa.controller.token_context + assert context.is_legacy + assert context.scope_type is None + assert not context.is_team_key + + @patch("lib.infrastructure.controller.Controller.get_current_user") + @patch("lib.infrastructure.controller.Controller.get_team") + def test_legacy_token_team_id_mismatch_raises(self, get_team, get_current_user): + with self.assertRaisesRegex(AppException, r"does not match the team"): + SAClient(token="token=123", team_id=42) diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py new file mode 100644 index 000000000..b656d1c5f --- /dev/null +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -0,0 +1,857 @@ +"""Unit tests for :class:`UpdateUserPermissionUseCase`. + +These exercise the client-side business rules for +``SAClient.grant_team_user_permissions`` / ``revoke_team_user_permissions`` +without a live backend, using a fake service provider whose permission data +mirrors the real ``teamUser`` permission groups: + + Team contributor permissions (ids 19-25) + 19 Manage Contributors' permissions (master) + 20 Invite Contributors to team + 21 Remove Contributors from team + 22 View Contributors' scores + 23 View Contributors' custom field values + 24 Edit Contributors' custom field values + 25 Access Workload management + + Team admin permissions (ids 26-30) + 26 View SDK Token (deprecated, not grantable) + 27 Access Orchestrate + 29 Access team API keys (master) + 30 Revoke members' personal API keys + +Each group has one master permission that implies every other permission in its +own group: granting it grants the group, and no permission in the group can be +revoked while it is still granted. + +The backend endpoint is declarative (``teamusers/setpermissions``): grant and +revoke are read-modify-write over the user's current permission set, and the +fake models that replace-with-full-set behavior. +""" + +from unittest import TestCase + +from src.superannotate.lib.core.entities.work_managament import WMUserTypeEnum +from src.superannotate.lib.core.reporter import Reporter +from src.superannotate.lib.core.usecases.work_management import ( + UpdateUserPermissionUseCase, +) +from src.superannotate.lib.infrastructure.utils import UserPermissionCache + +CONTRIBUTOR_PERMS = { + 19: "Manage Contributors’ permissions", + 20: "Invite Contributors to team", + 21: "Remove Contributors from team", + 22: "View Contributors’ scores", + 23: "View Contributors’ custom field values", + 24: "Edit Contributors’ custom field values", + 25: "Access Workload management", +} +ADMIN_PERMS = { + 26: "View SDK Token", + 27: "Access Orchestrate", + 29: "Access team API keys", + 30: "Revoke members' personal API keys", +} +# Master permission of each group, and the ids the backend no longer grants. +CONTRIBUTOR_MASTER_ID = 19 +ADMIN_MASTER_ID = 29 +DEPRECATED_IDS = {26} +GRANTABLE_ADMIN_PERMS = { + pid: name for pid, name in ADMIN_PERMS.items() if pid not in DEPRECATED_IDS +} +ALL_PERMS = {**CONTRIBUTOR_PERMS, **ADMIN_PERMS} +GROUPS = { + "Team contributor permissions": CONTRIBUTOR_PERMS, + "Team admin permissions": ADMIN_PERMS, +} + + +def _normalize(name: str) -> str: + return name.replace("’", "'").replace("‘", "'").lower() + + +class _FakeTeamUser: + def __init__( + self, + id_: int, + role: WMUserTypeEnum, + email: str, + user_permissions: list | None = None, + ): + self.id = id_ + self.role = role + self.email = email + self.user_permissions = [ + type("P", (), {"id": pid})() for pid in (user_permissions or []) + ] + + +class _FakeWorkManagementService: + """Models the declarative ``teamusers/setpermissions`` endpoint: the user's + permission set is replaced wholesale with the ids we send, and the resulting + set is echoed back (as the real endpoint does). + + With ``applies=False`` the endpoint accepts the write and changes nothing, + echoing the unchanged set back. That is how the backend answers a token that + may not update this user's permissions - a team-scoped API key, which acts as + the team rather than as a user.""" + + def __init__(self, granted, applies=True): + self.granted = set(granted) + self.calls = [] + self._applies = applies + + def set_team_user_permissions(self, contributor_id, permission_ids): + self.calls.append((contributor_id, list(permission_ids))) + if self._applies: + self.granted = set(permission_ids) + return sorted(self.granted) + + +class _FakeServiceProvider: + def __init__(self, granted=(), groups=None, name_by_id=None, applies=True): + self.work_management = _FakeWorkManagementService(granted, applies=applies) + self._groups = groups if groups is not None else GROUPS + self._name_by_id = name_by_id if name_by_id is not None else ALL_PERMS + + def get_team_user_permission_id_name_map(self): + return dict(self._name_by_id) + + def get_team_user_permission_groups(self): + return {name: dict(perms) for name, perms in self._groups.items()} + + def get_team_user_permission_id(self, name): + target = _normalize(name) + for pid, pname in self._name_by_id.items(): + if _normalize(pname) == target: + return pid + return None + + +class TestUpdateUserPermissionUseCase(TestCase): + EMAIL = "contributor@superannotate.com" + + def _run( + self, + permissions, + operation, + granted=(), + role=WMUserTypeEnum.Contributor, + user=None, + groups=None, + name_by_id=None, + current_perm_ids=None, + applies=True, + ): + # The use case reads the user's *current* permissions from the resolved + # team-user entity. Default the starting state to ``granted`` so callers + # can express "user currently holds X" with a single argument. + current = list(granted) if current_perm_ids is None else current_perm_ids + reporter = Reporter() + service_provider = _FakeServiceProvider( + granted=current, groups=groups, name_by_id=name_by_id, applies=applies + ) + team_user = _FakeTeamUser( + id_=101, + role=role, + email=self.EMAIL, + user_permissions=current, + ) + resolver = (lambda _: [team_user]) if user is not False else (lambda _: []) + use_case = UpdateUserPermissionUseCase( + reporter=reporter, + user=user if isinstance(user, (int, str)) else self.EMAIL, + permissions=permissions, + operation=operation, + service_provider=service_provider, + user_resolver=resolver, + ) + response = use_case.execute() + return response, reporter, service_provider + + @staticmethod + def _message(reporter, prefix): + for msg in reporter.info_messages: + if msg.startswith(prefix): + return msg + return None + + # ---- success / failure logging ------------------------------------- + + def test_grant_single_permission_success(self): + response, reporter, sp = self._run(["Invite Contributors to team"], "grant") + self.assertFalse(response.errors) + self.assertEqual( + self._message(reporter, "Successfully granted"), + f"Successfully granted [Invite Contributors to team] " + f"permission(s) for user: {self.EMAIL}.", + ) + self.assertIsNone(self._message(reporter, "Could not grant")) + # The full desired set is sent (empty current + the granted id). + self.assertEqual(sp.work_management.calls, [(101, [20])]) + self.assertEqual(sp.work_management.granted, {20}) + + def test_grant_already_granted_logs_failure(self): + _, reporter, sp = self._run( + ["Invite Contributors to team"], "grant", granted={20} + ) + self.assertIsNone(self._message(reporter, "Successfully granted")) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn( + "User already has [Invite Contributors to team] permission(s) granted.", + failure, + ) + # Nothing changes -> no network round-trip. + self.assertEqual(sp.work_management.calls, []) + + # ---- permissions the SDK adds itself stay silent when already fine ---- + # + # Only what the caller named is reported as "already has" / "already + # revoked". Master and Edit/View cascades, and the "*" expansion, are the + # SDK's own doing: a permission that was already in the requested state is + # the normal case there, not something to warn about. + + def test_grant_admin_master_is_silent_about_children_already_granted(self): + _, reporter, _ = self._run( + ["Access team API keys"], + "grant", + granted={27, 30}, + role=WMUserTypeEnum.TeamAdmin, + ) + self.assertEqual( + self._message(reporter, "Successfully granted"), + f"Successfully granted [Access team API keys] " + f"permission(s) for user: {self.EMAIL}.", + ) + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_grant_contributor_master_is_silent_about_children_already_granted(self): + _, reporter, _ = self._run([CONTRIBUTOR_PERMS[19]], "grant", granted={20, 22}) + self.assertIsNotNone(self._message(reporter, "Successfully granted")) + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_grant_edit_is_silent_when_view_already_granted(self): + _, reporter, _ = self._run([CONTRIBUTOR_PERMS[24]], "grant", granted={23}) + self.assertEqual( + self._message(reporter, "Successfully granted"), + f"Successfully granted [{CONTRIBUTOR_PERMS[24]}] " + f"permission(s) for user: {self.EMAIL}.", + ) + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_revoke_view_is_silent_when_edit_not_granted(self): + _, reporter, _ = self._run([CONTRIBUTOR_PERMS[23]], "revoke", granted={23}) + self.assertEqual( + self._message(reporter, "Successfully revoked"), + f"Successfully revoked [{CONTRIBUTOR_PERMS[23]}] " + f"permission(s) for user: {self.EMAIL}.", + ) + self.assertIsNone(self._message(reporter, "Could not revoke")) + + def test_grant_wildcard_is_silent_about_permissions_already_granted(self): + _, reporter, _ = self._run( + "*", "grant", granted={27}, role=WMUserTypeEnum.TeamAdmin + ) + self.assertIsNotNone(self._message(reporter, "Successfully granted")) + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_explicitly_named_permission_still_reports_already_granted(self): + # The silence above must not swallow the case the story requires: a + # permission the caller named is reported even though it is a no-op. + _, reporter, _ = self._run( + ["Access Orchestrate"], + "grant", + granted={27}, + role=WMUserTypeEnum.TeamAdmin, + ) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn( + "User already has [Access Orchestrate] permission(s) granted.", failure + ) + + def test_cascade_permission_that_really_failed_is_still_reported(self): + # Revoking View cascades to Edit, but the master blocks both. Neither + # reached the requested state, so both belong in the failure - silence + # is only for permissions that were already fine. + _, reporter, sp = self._run( + [CONTRIBUTOR_PERMS[23]], "revoke", granted={19, 20, 21, 22, 23, 24, 25} + ) + self.assertEqual(sp.work_management.calls, []) + failure = self._message(reporter, "Could not revoke") + self.assertIsNotNone(failure) + self.assertIn(CONTRIBUTOR_PERMS[23], failure) + self.assertIn(CONTRIBUTOR_PERMS[24], failure) + self.assertIn("is granted, it must be revoked before", failure) + + def test_revoke_single_permission_success(self): + _, reporter, sp = self._run( + ["Invite Contributors to team"], "revoke", granted={20} + ) + self.assertEqual( + self._message(reporter, "Successfully revoked"), + f"Successfully revoked [Invite Contributors to team] " + f"permission(s) for user: {self.EMAIL}.", + ) + self.assertEqual(sp.work_management.calls, [(101, [])]) + self.assertEqual(sp.work_management.granted, set()) + + def test_revoke_already_revoked_logs_failure(self): + _, reporter, sp = self._run(["Invite Contributors to team"], "revoke") + failure = self._message(reporter, "Could not revoke") + self.assertIsNotNone(failure) + self.assertIn( + "[Invite Contributors to team] permission(s) were already revoked " + "for the user.", + failure, + ) + self.assertEqual(sp.work_management.calls, []) + + # ---- a token that is not allowed to update the user ------------------ + + def test_grant_not_applied_by_backend_lists_the_api_key_reason(self): + # A team-scoped API key acts as the team, with no user behind it, so the + # backend accepts the write and applies nothing. Everything attempted is + # reported as failed, and the key is one of the possible reasons. + _, reporter, sp = self._run( + ["Invite Contributors to team"], "grant", applies=False + ) + self.assertEqual(sp.work_management.calls, [(101, [20])]) + self.assertIsNone(self._message(reporter, "Successfully granted")) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn("[Invite Contributors to team]", failure) + self.assertIn( + "The API key used does not have sufficient permissions to perform " + "this action.", + failure, + ) + self.assertEqual(sp.work_management.granted, set()) + + def test_revoke_not_applied_by_backend_lists_the_api_key_reason(self): + _, reporter, sp = self._run( + ["Invite Contributors to team"], "revoke", granted={20}, applies=False + ) + self.assertEqual(sp.work_management.calls, [(101, [])]) + self.assertIsNone(self._message(reporter, "Successfully revoked")) + failure = self._message(reporter, "Could not revoke") + self.assertIsNotNone(failure) + self.assertIn("[Invite Contributors to team]", failure) + self.assertIn( + "The API key used does not have sufficient permissions to perform " + "this action.", + failure, + ) + self.assertEqual(sp.work_management.granted, {20}) + + def test_admin_wildcard_grant_not_applied_reports_every_permission(self): + _, reporter, sp = self._run( + "*", "grant", role=WMUserTypeEnum.TeamAdmin, applies=False + ) + self.assertEqual( + sp.work_management.calls, [(101, sorted(GRANTABLE_ADMIN_PERMS))] + ) + self.assertIsNone(self._message(reporter, "Successfully granted")) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + for name in GRANTABLE_ADMIN_PERMS.values(): + self.assertIn(name, failure) + self.assertIn( + "The API key used does not have sufficient permissions to perform " + "this action.", + failure, + ) + self.assertEqual(sp.work_management.granted, set()) + + # ---- cascades ------------------------------------------------------ + + def test_grant_master_cascades_all_contributor_permissions(self): + _, reporter, sp = self._run(["Manage Contributors' permissions"], "grant") + # The desired set is the whole contributor group (master implies all). + self.assertEqual( + sp.work_management.calls, + [(101, [19, 20, 21, 22, 23, 24, 25])], + ) + success = self._message(reporter, "Successfully granted") + self.assertIsNotNone(success) + for fragment in ( + "Manage Contributors", + "Invite Contributors to team", + "Remove Contributors from team", + "View Contributors’ scores", + "View Contributors’ custom field values", + "Edit Contributors’ custom field values", + "Access Workload management", + ): + self.assertIn(fragment, success) + self.assertEqual(sp.work_management.granted, {19, 20, 21, 22, 23, 24, 25}) + + def test_grant_master_cascade_derived_from_live_group_data(self): + # The master cascade is derived from the permission-groups response, + # not hardcoded. When a contributor permission is absent for the team + # (here id 25 "Access Workload management"), granting the master must + # cascade only to the permissions that actually exist. + contributor_perms = { + 19: "Manage Contributors’ permissions", + 20: "Invite Contributors to team", + 21: "Remove Contributors from team", + 22: "View Contributors’ scores", + 23: "View Contributors’ custom field values", + 24: "Edit Contributors’ custom field values", + } + groups = { + "Team contributor permissions": contributor_perms, + "Team admin permissions": ADMIN_PERMS, + } + _, _, sp = self._run( + ["Manage Contributors' permissions"], + "grant", + groups=groups, + name_by_id={**contributor_perms, **ADMIN_PERMS}, + ) + self.assertEqual( + sp.work_management.calls, + [(101, [19, 20, 21, 22, 23, 24])], + ) + self.assertEqual(sp.work_management.granted, {19, 20, 21, 22, 23, 24}) + + def test_grant_edit_custom_fields_cascades_view(self): + _, reporter, sp = self._run(["Edit Contributors' custom field values"], "grant") + # Desired set includes both Edit (24) and the cascaded View (23). + self.assertEqual(sp.work_management.calls, [(101, [23, 24])]) + success = self._message(reporter, "Successfully granted") + self.assertIn("Edit Contributors’ custom field values", success) + self.assertIn("View Contributors’ custom field values", success) + + def test_revoke_view_custom_fields_cascades_edit(self): + _, reporter, sp = self._run( + ["View Contributors' custom field values"], + "revoke", + granted={23, 24}, + ) + # Revoking View also revokes Edit -> desired set drops both. + self.assertEqual(sp.work_management.calls, [(101, [])]) + success = self._message(reporter, "Successfully revoked") + self.assertIn("View Contributors’ custom field values", success) + self.assertIn("Edit Contributors’ custom field values", success) + self.assertEqual(sp.work_management.granted, set()) + + # ---- master is now removable (new endpoint) ------------------------ + + def test_revoke_master_leaves_other_members(self): + # Revoking the master by name drops only the master; the other + # contributor permissions the user holds remain. + _, reporter, sp = self._run( + ["Manage Contributors' permissions"], + "revoke", + granted={19, 20, 21}, + ) + self.assertEqual(sp.work_management.calls, [(101, [20, 21])]) + self.assertEqual(sp.work_management.granted, {20, 21}) + success = self._message(reporter, "Successfully revoked") + self.assertIn("Manage Contributors", success) + + def test_revoke_member_while_master_enabled_is_blocked(self): + # A master holder realistically has the whole group (master implies + # all). Revoking a single member is forced back into the desired set by + # the master invariant (no change) and reported as a failure telling + # the user to revoke the master first. + _, reporter, sp = self._run( + ["Invite Contributors to team"], + "revoke", + granted={19, 20, 21, 22, 23, 24, 25}, + ) + # Desired set == current -> no network round-trip. + self.assertEqual(sp.work_management.calls, []) + self.assertEqual(sp.work_management.granted, {19, 20, 21, 22, 23, 24, 25}) + failure = self._message(reporter, "Could not revoke") + self.assertIsNotNone(failure) + self.assertIn("[Invite Contributors to team]", failure) + # The master name is the canonical one from the live group data, so it + # carries the backend's curly apostrophe; match around it. + self.assertIn("Manage Contributors", failure) + self.assertIn(" permissions is granted, it must be revoked before", failure) + # Only the contributor master is mentioned; the admin master is + # irrelevant to a contributor's failure. + self.assertNotIn("Access team API keys", failure) + + # ---- "*" is scoped to the user's role ------------------------------ + + def test_wildcard_contributor_role_grants_only_contributor_permissions(self): + _, reporter, sp = self._run("*", "grant", role=WMUserTypeEnum.Contributor) + _, sent = sp.work_management.calls[0] + self.assertEqual(set(sent), set(CONTRIBUTOR_PERMS)) + self.assertFalse(set(sent) & set(ADMIN_PERMS)) + + def test_wildcard_admin_role_grants_only_admin_permissions(self): + _, reporter, sp = self._run("*", "grant", role=WMUserTypeEnum.TeamAdmin) + _, sent = sp.work_management.calls[0] + # "*" covers the admin group except the ids the backend won't grant. + self.assertEqual(set(sent), set(GRANTABLE_ADMIN_PERMS)) + self.assertFalse(set(sent) & set(CONTRIBUTOR_PERMS)) + success = self._message(reporter, "Successfully granted") + self.assertIn("Access Orchestrate", success) + self.assertIn("Access team API keys", success) + self.assertIn("Revoke members' personal API keys", success) + + def test_wildcard_admin_role_skips_deprecated_permission(self): + # id 26 is still in the group but is not grantable, so it must not be + # sent and must not be reported as a failure. + _, reporter, sp = self._run("*", "grant", role=WMUserTypeEnum.TeamAdmin) + _, sent = sp.work_management.calls[0] + self.assertNotIn(26, sent) + self.assertIsNone(self._message(reporter, "Could not grant")) + + # ---- admin master: "Access team API keys" --------------------------- + + def test_grant_admin_master_cascades_to_group(self): + # Granting the admin master grants every other grantable admin + # permission, mirroring the contributor master. + _, reporter, sp = self._run( + ["Access team API keys"], "grant", role=WMUserTypeEnum.TeamAdmin + ) + _, sent = sp.work_management.calls[0] + self.assertEqual(set(sent), set(GRANTABLE_ADMIN_PERMS)) + success = self._message(reporter, "Successfully granted") + for name in GRANTABLE_ADMIN_PERMS.values(): + self.assertIn(name, success) + # The deprecated id is neither sent nor reported. + self.assertNotIn(26, sent) + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_grant_admin_master_is_case_insensitive(self): + _, _, sp = self._run( + ["access TEAM api KEYS"], "grant", role=WMUserTypeEnum.TeamAdmin + ) + _, sent = sp.work_management.calls[0] + self.assertEqual(set(sent), set(GRANTABLE_ADMIN_PERMS)) + + def test_revoke_admin_permission_while_admin_master_enabled_is_blocked(self): + _, reporter, sp = self._run( + ["Access Orchestrate"], + "revoke", + granted=set(GRANTABLE_ADMIN_PERMS), + role=WMUserTypeEnum.TeamAdmin, + ) + # Desired set == current -> no network round-trip, nothing revoked. + self.assertEqual(sp.work_management.calls, []) + self.assertEqual(sp.work_management.granted, set(GRANTABLE_ADMIN_PERMS)) + failure = self._message(reporter, "Could not revoke") + self.assertIsNotNone(failure) + self.assertIn("[Access Orchestrate]", failure) + self.assertIn( + "If Access team API keys is granted, it must be revoked before " + "[Access Orchestrate] can be revoked for this user.", + failure, + ) + # The hint stays scoped to the admin group. + self.assertNotIn("Manage Contributors", failure) + + def test_revoke_admin_master_succeeds_and_leaves_group_granted(self): + # The master itself stays revocable; the permissions it implied remain. + _, reporter, sp = self._run( + ["Access team API keys"], + "revoke", + granted=set(GRANTABLE_ADMIN_PERMS), + role=WMUserTypeEnum.TeamAdmin, + ) + _, sent = sp.work_management.calls[0] + self.assertEqual(set(sent), set(GRANTABLE_ADMIN_PERMS) - {ADMIN_MASTER_ID}) + self.assertEqual( + self._message(reporter, "Successfully revoked"), + f"Successfully revoked [Access team API keys] " + f"permission(s) for user: {self.EMAIL}.", + ) + + # ---- a team without "Access Workload management" (id 25) ------------- + + # Some teams do not expose id 25. Nothing may hardcode it: "*", the master + # cascade and the master invariant must all follow the live group data. + PERMS_NO_25 = {pid: n for pid, n in CONTRIBUTOR_PERMS.items() if pid != 25} + + def _run_without_25(self, permissions, operation, **kw): + return self._run( + permissions, + operation, + groups={ + "Team contributor permissions": self.PERMS_NO_25, + "Team admin permissions": ADMIN_PERMS, + }, + name_by_id={**self.PERMS_NO_25, **ADMIN_PERMS}, + **kw, + ) + + def test_wildcard_grant_without_workload_management(self): + _, reporter, sp = self._run_without_25("*", "grant") + _, sent = sp.work_management.calls[0] + self.assertEqual(set(sent), set(self.PERMS_NO_25)) + self.assertNotIn(25, sent) + success = self._message(reporter, "Successfully granted") + self.assertNotIn("Access Workload management", success) + # A missing permission is not a failure: nothing to report. + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_master_cascade_without_workload_management(self): + _, reporter, sp = self._run_without_25([CONTRIBUTOR_PERMS[19]], "grant") + _, sent = sp.work_management.calls[0] + self.assertEqual(set(sent), set(self.PERMS_NO_25)) + self.assertNotIn(25, sent) + self.assertIsNone(self._message(reporter, "Could not grant")) + + def test_wildcard_revoke_without_workload_management(self): + _, _, sp = self._run_without_25("*", "revoke", granted=set(self.PERMS_NO_25)) + self.assertEqual(sp.work_management.calls, [(101, [])]) + + def test_revoke_blocked_by_master_without_workload_management(self): + # The invariant must restore only the ids the team actually has. + _, reporter, sp = self._run_without_25( + [CONTRIBUTOR_PERMS[20]], "revoke", granted=set(self.PERMS_NO_25) + ) + self.assertEqual(sp.work_management.calls, []) + self.assertEqual(sp.work_management.granted, set(self.PERMS_NO_25)) + self.assertIsNotNone(self._message(reporter, "Could not revoke")) + + def test_requesting_workload_management_when_absent_is_invalid(self): + # The name cannot resolve at all (the flat name lookup and the groups come + # from the same payload), so it is reported as an invalid permission. + _, reporter, sp = self._run_without_25(["Access Workload management"], "grant") + self.assertEqual(sp.work_management.calls, []) + failure = self._message(reporter, "Could not grant") + self.assertIn("[Access Workload management]", failure) + self.assertIn("Provided permission(s) were invalid.", failure) + + # ---- cascades tolerate teams that lack a cascade partner ------------- + + def test_grant_cascade_skips_a_partner_the_team_does_not_have(self): + # The Edit -> View cascade is hardcoded by id, but a team need not expose + # both (id 25 is already absent on some teams). The missing partner must + # be dropped rather than sent to the backend / looked up for reporting. + perms = {19: CONTRIBUTOR_PERMS[19], 24: CONTRIBUTOR_PERMS[24]} + _, reporter, sp = self._run( + [CONTRIBUTOR_PERMS[24]], + "grant", + groups={"Team contributor permissions": perms}, + name_by_id=perms, + ) + _, sent = sp.work_management.calls[0] + self.assertEqual(sent, [24]) + self.assertEqual( + self._message(reporter, "Successfully granted"), + f"Successfully granted [{CONTRIBUTOR_PERMS[24]}] " + f"permission(s) for user: {self.EMAIL}.", + ) + + def test_revoke_cascade_skips_a_partner_the_team_does_not_have(self): + perms = {19: CONTRIBUTOR_PERMS[19], 23: CONTRIBUTOR_PERMS[23]} + _, reporter, sp = self._run( + [CONTRIBUTOR_PERMS[23]], + "revoke", + granted={23}, + groups={"Team contributor permissions": perms}, + name_by_id=perms, + ) + _, sent = sp.work_management.calls[0] + self.assertEqual(sent, []) + self.assertEqual( + self._message(reporter, "Successfully revoked"), + f"Successfully revoked [{CONTRIBUTOR_PERMS[23]}] " + f"permission(s) for user: {self.EMAIL}.", + ) + + def test_master_invariant_is_scoped_to_the_users_role_group(self): + # A permission held from outside the user's role (stale data after a role + # change) must not drag its own group in. Here an admin still carries the + # contributor master (19): granting an admin permission must not expand + # into the contributor group. + _, _, sp = self._run( + ["Access Orchestrate"], + "grant", + granted={CONTRIBUTOR_MASTER_ID}, + role=WMUserTypeEnum.TeamAdmin, + ) + _, sent = sp.work_management.calls[0] + # The stale id is left untouched, but no other contributor id appears. + self.assertEqual(set(sent), {CONTRIBUTOR_MASTER_ID, 27}) + self.assertFalse( + (set(sent) - {CONTRIBUTOR_MASTER_ID}) & set(CONTRIBUTOR_PERMS), + f"contributor group leaked into an admin's set: {sent}", + ) + + def test_revoke_hint_names_the_masters_of_the_users_role(self): + # With no failed id to trace (the name does not resolve), the hint must + # still name the master of the user's own role, not the other group's. + _, reporter, _ = self._run( + ["NoSuchPermission"], "revoke", role=WMUserTypeEnum.TeamAdmin + ) + failure = self._message(reporter, "Could not revoke") + self.assertIn("If Access team API keys is granted", failure) + self.assertNotIn("Manage Contributors", failure) + + _, reporter, _ = self._run( + ["NoSuchPermission"], "revoke", role=WMUserTypeEnum.Contributor + ) + failure = self._message(reporter, "Could not revoke") + self.assertIn("Manage Contributors", failure) + self.assertNotIn("Access team API keys", failure) + + def test_revoke_wildcard_admin_clears_group_including_master(self): + _, reporter, sp = self._run( + "*", + "revoke", + granted=set(GRANTABLE_ADMIN_PERMS), + role=WMUserTypeEnum.TeamAdmin, + ) + self.assertEqual(sp.work_management.calls, [(101, [])]) + self.assertEqual(sp.work_management.granted, set()) + + # ---- revoke "*" clears the whole set (incl. master) ---------------- + + def test_revoke_wildcard_contributor_clears_current_permissions(self): + _, reporter, sp = self._run("*", "revoke", granted={19, 20, 22}) + self.assertEqual(sp.work_management.calls, [(101, [])]) + success = self._message(reporter, "Successfully revoked") + self.assertIsNotNone(success) + self.assertIn("Manage Contributors", success) + self.assertIn("Invite Contributors to team", success) + self.assertIn("View Contributors’ scores", success) + self.assertEqual(sp.work_management.granted, set()) + + def test_revoke_wildcard_admin_clears_current_permissions(self): + _, reporter, sp = self._run( + "*", + "revoke", + granted={26, 27}, + role=WMUserTypeEnum.TeamAdmin, + ) + self.assertEqual(sp.work_management.calls, [(101, [])]) + self.assertEqual(sp.work_management.granted, set()) + + def test_revoke_wildcard_with_no_permissions_is_noop(self): + # Nothing currently held -> desired set already empty, no backend call. + _, reporter, sp = self._run("*", "revoke", granted=set()) + self.assertEqual(sp.work_management.calls, []) + self.assertIsNone(self._message(reporter, "Successfully revoked")) + self.assertIsNone(self._message(reporter, "Could not revoke")) + + # ---- role mismatch (admin perm <-> contributor) --------------------- + + def test_grant_admin_permission_for_contributor_logs_role_mismatch(self): + # An admin-only permission requested for a contributor must not be + # sent to the backend; the SDK reports a role-mismatch failure. + _, reporter, sp = self._run(["View SDK Token"], "grant") + self.assertEqual(sp.work_management.calls, []) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn("[View SDK Token]", failure) + self.assertIn( + "User role does not allow [View SDK Token] permission(s).", + failure, + ) + self.assertIsNone(self._message(reporter, "Successfully granted")) + + def test_grant_contributor_permission_for_admin_logs_role_mismatch(self): + # A contributor-only permission requested for an admin must not be + # sent to the backend; the SDK reports a role-mismatch failure. + _, reporter, sp = self._run( + ["Invite Contributors to team"], "grant", role=WMUserTypeEnum.TeamAdmin + ) + self.assertEqual(sp.work_management.calls, []) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn("[Invite Contributors to team]", failure) + self.assertIn( + "User role does not allow [Invite Contributors to team] permission(s).", + failure, + ) + self.assertIsNone(self._message(reporter, "Successfully granted")) + + def test_grant_mixed_valid_and_role_mismatch_grants_valid_only(self): + # A valid contributor permission mixed with a role-invalid admin one + # must grant the valid one and report the admin one as a failure (the + # role-invalid permission is never sent, so it cannot cause the backend + # to reject the whole set). + _, reporter, sp = self._run( + ["Invite Contributors to team", "View SDK Token"], "grant" + ) + self.assertEqual(sp.work_management.calls, [(101, [20])]) + self.assertIn( + "Invite Contributors to team", + self._message(reporter, "Successfully granted"), + ) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn("[View SDK Token]", failure) + + # ---- name resolution ----------------------------------------------- + + def test_invalid_permission_logs_failure_and_skips_backend(self): + _, reporter, sp = self._run(["NonExistentPermission"], "grant") + self.assertEqual(sp.work_management.calls, []) + failure = self._message(reporter, "Could not grant") + self.assertIsNotNone(failure) + self.assertIn("[NonExistentPermission]", failure) + self.assertIn("Provided permission(s) were invalid.", failure) + + def test_mixed_valid_and_invalid_logs_both(self): + _, reporter, sp = self._run( + ["Invite Contributors to team", "NonExistentPermission"], "grant" + ) + self.assertEqual(sp.work_management.calls, [(101, [20])]) + self.assertIn( + "Invite Contributors to team", + self._message(reporter, "Successfully granted"), + ) + self.assertIn( + "NonExistentPermission", self._message(reporter, "Could not grant") + ) + + def test_case_insensitive_permission_name(self): + _, reporter, sp = self._run(["invite contributors to team"], "grant") + self.assertEqual(sp.work_management.calls, [(101, [20])]) + self.assertIsNotNone(self._message(reporter, "Successfully granted")) + + def test_straight_apostrophe_resolves_to_canonical_name(self): + # User supplies a straight apostrophe; backend stores a curly one. + _, reporter, sp = self._run(["View Contributors' scores"], "grant") + self.assertEqual(sp.work_management.calls, [(101, [22])]) + self.assertEqual( + self._message(reporter, "Successfully granted"), + f"Successfully granted [View Contributors’ scores] " + f"permission(s) for user: {self.EMAIL}.", + ) + + def test_duplicate_permission_names_deduplicated(self): + _, _, sp = self._run( + ["Invite Contributors to team", "invite contributors to team"], + "grant", + ) + self.assertEqual(sp.work_management.calls, [(101, [20])]) + + # ---- error paths --------------------------------------------------- + + def test_empty_permissions_returns_error(self): + response, reporter, sp = self._run([], "grant") + self.assertEqual(response.errors, "Permission(s) cannot be empty.") + self.assertEqual(reporter.info_messages, []) + self.assertEqual(sp.work_management.calls, []) + + def test_unknown_user_returns_error(self): + response, reporter, sp = self._run( + ["Invite Contributors to team"], "grant", user=False + ) + self.assertEqual(response.errors, "User not found.") + self.assertEqual(reporter.info_messages, []) + self.assertEqual(sp.work_management.calls, []) + + +class TestUserPermissionNameNormalization(TestCase): + def test_normalizes_curly_apostrophe_and_case(self): + self.assertEqual( + UserPermissionCache._normalize_name("View Contributors’ SCORES"), + "view contributors' scores", + ) + + def test_left_and_right_single_quotes_normalized(self): + self.assertEqual(UserPermissionCache._normalize_name("A‘b’c"), "a'b'c")