From d94e654720c1062098cf800a714278a34d4ced69 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 14 Jul 2026 16:28:15 +0400 Subject: [PATCH 01/13] add integration for MM in upload_annotations --- .../lib/app/interface/sdk_interface.py | 29 ++++++++ .../lib/core/usecases/annotations.py | 10 ++- .../lib/infrastructure/controller.py | 2 + .../annotations/test_upload_annotations.py | 66 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index ee940303..3e17ed4a 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3702,6 +3702,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 +3724,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 +3764,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 +3784,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 +3803,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) diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 3b5d6f48..a4823d63 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 @@ -1745,6 +1746,7 @@ def __init__( user: UserEntity, keep_status: bool = False, transform_version: str = None, + integration: IntegrationEntity = None, ): super().__init__(reporter) self._project = project @@ -1758,6 +1760,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 +1898,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() diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index c14cd69e..d55673fc 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1432,6 +1432,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 +1444,7 @@ def upload_multiple( keep_status=keep_status, user=user, transform_version="llmJsonV2", + integration=integration, ) else: use_case = usecases.UploadAnnotationsUseCase( diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index a86e62e8..59b842af 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 @@ -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] From 5114860b4a04dee1c756e69abe109adf23f376a0 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 20 Jul 2026 10:25:56 +0400 Subject: [PATCH 02/13] Code styly updates --- .../lib/core/serviceproviders.py | 77 ++++++++++--------- .../lib/core/usecases/annotations.py | 26 +++---- .../lib/infrastructure/controller.py | 10 +-- src/superannotate/lib/infrastructure/utils.py | 10 ++- 4 files changed, 60 insertions(+), 63 deletions(-) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 54960f5a..cf32d060 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 @@ -71,8 +72,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 +81,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 +122,7 @@ def list_custom_field_templates( self, entity: CustomFieldEntityEnum, parent_entity: CustomFieldEntityEnum, - context: dict = None, + context: dict | None = None, ): raise NotImplementedError @@ -169,7 +170,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 @@ -291,7 +292,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 +392,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 +435,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 +462,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 +558,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 +569,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 +589,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 +602,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 +613,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 +625,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 +633,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 +667,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 +683,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 +730,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 +757,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 +767,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 +788,7 @@ def set_score_values( raise NotImplementedError -class BaseServiceProvider: +class BaseServiceProvider(metaclass=ABCMeta): projects: BaseProjectService folders: BaseFolderService items: BaseItemService @@ -884,8 +885,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 +915,9 @@ def get_project_images_count( raise NotImplementedError @abstractmethod - def search_team_contributors(self, condition: Condition = None) -> ServiceResponse: + def search_team_contributors( + self, condition: Condition | None = None + ) -> ServiceResponse: raise NotImplementedError @abstractmethod diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index a4823d63..9946174f 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -131,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 = ( @@ -162,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) @@ -437,7 +437,7 @@ def execute(self): "failed": failed, "skipped": skipped, } - return self._response + return self._response class UploadAnnotationsFromFolderUseCase(BaseReportableUseCase): @@ -610,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) @@ -1191,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) @@ -1254,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, @@ -2050,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/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index d55673fc..af749f24 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 @@ -1677,12 +1677,6 @@ def org_id(self): 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 - @property def team(self): return self._team @@ -2016,7 +2010,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/utils.py b/src/superannotate/lib/infrastructure/utils.py index 490cb767..9b44a16c 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( From d331a920516ec4045c71d935ff85c64f4888829f Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 21 Jul 2026 17:12:18 +0400 Subject: [PATCH 03/13] Add grant/revoke team user permissions (FRIDAY-5409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SAClient.grant_team_user_permissions() and SAClient.revoke_team_user_permissions() to manage team-level user permissions, with role-based "*" resolution (contributor vs admin groups) and case-/apostrophe-insensitive permission name lookup. Move the business logic into a new UpdateUserPermissionUseCase, which mirrors the documented cascades client-side because the work-management backend does not auto-cascade through the permissions API: - granting "Manage Contributors' permissions" (id 19) grants every contributor permission (20-25); - granting "Edit Contributors' custom field values" (24) also grants "View Contributors' custom field values" (23); - revoking "View Contributors' custom field values" (23) also revokes "Edit Contributors' custom field values" (24). Cascade rules are keyed by permission id in lib.core constants. Supporting infrastructure: - TeamUserPermissionCache + CachedWorkManagementRepository methods (get_team_user_permission_id, _id_name_map, _groups); - WorkManagementService.edit_team_user_permissions; - abstract methods on the service provider interfaces. Tests: - integration tests covering grant/revoke by email and id, wildcard, already-granted/revoked logging, invalid/mixed permissions, apostrophe normalization, and the cascade cases (master grant, revoke block while master enabled, view->edit custom field revoke); - unit tests for UpdateUserPermissionUseCase cascade/role logic. Also fix a latent bug in coco_converter.get_image_dimensions where the PIL fallback called img.size() (a tuple property) and always raised TypeError; use img.size so the fallback actually works. Bump pillow to 12.3 (latest 12.x; audited usage — no deprecated APIs) and aiohttp to 3.14; bump version to 4.5.9dev1. --- CHANGELOG.rst | 5 + docs/source/api_reference/api_team.rst | 2 + requirements.txt | 4 +- src/superannotate/__init__.py | 2 +- .../coco_converters/coco_converter.py | 2 +- .../lib/app/interface/sdk_interface.py | 140 +++++++ src/superannotate/lib/core/__init__.py | 21 + .../lib/core/serviceproviders.py | 31 ++ .../lib/core/usecases/__init__.py | 1 + .../lib/core/usecases/work_management.py | 212 ++++++++++ .../lib/infrastructure/controller.py | 23 ++ .../lib/infrastructure/serviceprovider.py | 15 + .../services/work_management.py | 96 ++++- src/superannotate/lib/infrastructure/utils.py | 60 ++- .../test_team_user_permissions.py | 330 +++++++++++++++ .../test_team_user_permissions_usecase.py | 380 ++++++++++++++++++ 16 files changed, 1308 insertions(+), 16 deletions(-) create mode 100644 src/superannotate/lib/core/usecases/work_management.py create mode 100644 tests/integration/work_management/test_team_user_permissions.py create mode 100644 tests/unit/test_team_user_permissions_usecase.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4eb7e75d..99de584b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,11 @@ All release highlights of this project will be documented in this file. 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/docs/source/api_reference/api_team.rst b/docs/source/api_reference/api_team.rst index 6178dcf7..abb70185 100644 --- a/docs/source/api_reference/api_team.rst +++ b/docs/source/api_reference/api_team.rst @@ -18,3 +18,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/requirements.txt b/requirements.txt index dbb26955..a5570d7a 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 91697c91..5b54bd4c 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.8" +__version__ = "4.5.9dev1" 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 35da0036..2171bf70 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/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 3e17ed4a..768f9fe0 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1223,6 +1223,146 @@ 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 + + - "Manage team API keys": Only for Team Admins. Allows Team Admins to + create, rotate, and revoke team API keys. Keys may grant permissions + beyond those assigned in the UI. + - "Orchestrate": Only for Team Admins. Allows Team Admins to create and + monitor Orchestrate pipelines, as well as access Secrets and Proxies. + - "Revoke other members API keys": Only for Team Admins. Allows Team + Admins to revoke other Team Admins' and Owner's personal API keys. + - "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=["View SDK Token"], + 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`. + :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. diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 3d886672..c36c6b31 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -153,6 +153,27 @@ 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. +TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS = { + "id": 19, + "name": "Manage Contributors’ permissions", +} +# Granting "Manage Contributors' permissions" grants every contributor +# permission; granting "Edit Contributors' custom field values" also grants +# "View Contributors' custom field values". +TEAM_USER_PERMISSION_GRANT_CASCADE = { + 19: [20, 21, 22, 23, 24, 25], + 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/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index cf32d060..25da8b31 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -260,6 +260,25 @@ def edit_project_user_permissions( ) -> dict: raise NotImplementedError + @abstractmethod + def edit_team_user_permissions( + self, + contributor_ids: list[int], + permission_ids: list[int], + operation: Literal["grant", "revoke"], + chunk_size=100, + ) -> dict: + raise NotImplementedError + + @abstractmethod + def set_team_user_permissions( + self, + contributor_ids: list[int], + permission_ids: list[int], + chunk_size=100, + ) -> dict: + raise NotImplementedError + @abstractmethod def update_annotation_class( self, @@ -920,6 +939,18 @@ def search_team_contributors( ) -> ServiceResponse: raise NotImplementedError + @abstractmethod + 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 def invite_contributors( self, team_id: int, team_role: int, emails: list[str] diff --git a/src/superannotate/lib/core/usecases/__init__.py b/src/superannotate/lib/core/usecases/__init__.py index 7d34f674..6e305a82 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/work_management.py b/src/superannotate/lib/core/usecases/work_management.py new file mode 100644 index 00000000..bc41080a --- /dev/null +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from typing 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). +MANAGE_CONTRIBUTORS_ID = constants.TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS["id"] + + +class UpdateUserPermissionUseCase(BaseReportableUseCase): + """Grant or revoke team-user permissions for a single user. + + Encapsulates the business rules that the work-management permissions API + does not enforce on its own: + + - "*" resolves only to the permissions allowed for the user's role + (the backend rejects the whole batch otherwise); + - 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 through the permissions API; + - per-permission success / failure is reported through the reporter. + """ + + 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() + + resolved_ids, unresolved_names = self._resolve_permissions( + team_user.role, name_by_id, groups + ) + + affected_ids: set[int] = set() + ordered_ids = self._order_team_permission_ids( + self._cascade_team_permission_ids(resolved_ids, self._operation) + ) + if ordered_ids: + affected_ids = self._apply(team_user.id, ordered_ids) + + self._log(ordered_ids, affected_ids, unresolved_names, team_user.email) + 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 _apply(self, contributor_id: int, permission_ids: list[int]) -> set[int]: + response = self._service_provider.work_management.edit_team_user_permissions( + contributor_ids=[contributor_id], + permission_ids=permission_ids, + operation=self._operation, + ) + section_key = "add" if self._operation == "grant" else "remove" + entry = next( + ( + c + for c in (response.get(section_key) or []) + if c.get("id") == contributor_id + ), + None, + ) + if not entry: + return set() + return {p["id"] for p in (entry.get("userPermissions") or [])} + + def _resolve_permissions( + self, + role: WMUserTypeEnum, + name_by_id: dict[int, str], + groups: dict[str, dict[int, str]] | None, + ) -> tuple[list[int], list[str]]: + if self._permissions == "*": + return list( + self._role_team_user_permission_map(role, name_by_id, groups).keys() + ), [] + + resolved_ids: list[int] = [] + seen_ids: set[int] = set() + unresolved_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 seen_ids: + resolved_ids.append(pid) + seen_ids.add(pid) + return resolved_ids, unresolved_names + + def _log( + self, + ordered_ids: list[int], + affected_ids: set[int], + unresolved_names: list[str], + user_email: str, + ) -> None: + name_by_id = self._service_provider.get_team_user_permission_id_name_map() + succeeded_names = [ + name_by_id[pid] for pid in ordered_ids if pid in affected_ids + ] + failed_names = [ + name_by_id[pid] for pid in ordered_ids if pid not in affected_ids + ] + 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" + f"- Provided permission(s) were invalid." + ) + else: + reasons = ( + f"- {failed_str} permission(s) were already revoked for the user.\n" + f"- Provided permission(s) were invalid.\n" + f"- If Manage Contributors' permissions is granted, it must be " + f"revoked before {failed_str} can be revoked for this user." + ) + 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) + + @staticmethod + def _cascade_team_permission_ids( + requested: list[int], operation: PermissionOperation + ) -> list[int]: + """Expand requested permission ids with cascade dependents (by id).""" + cascade = ( + constants.TEAM_USER_PERMISSION_GRANT_CASCADE + if operation == "grant" + else constants.TEAM_USER_PERMISSION_REVOKE_CASCADE + ) + 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 + + @staticmethod + def _order_team_permission_ids(perm_ids: list[int]) -> list[int]: + # The master permission auto-grants the other contributor permissions + # and blocks their revocation while enabled, so process it first. + if MANAGE_CONTRIBUTORS_ID in perm_ids: + return [MANAGE_CONTRIBUTORS_ID] + [ + pid for pid in perm_ids if pid != MANAGE_CONTRIBUTORS_ID + ] + return list(perm_ids) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index af749f24..c2edc57b 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -608,6 +608,29 @@ 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): diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 3b1d7a38..2545616f 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -166,6 +166,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: diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 0a4d2b7d..99e04ff9 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -78,7 +78,7 @@ class WorkManagementService(BaseWorkManagementService): 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_USER_PERMISSIONS = "customentities/edit" URL_PERMISSION_GROUPS = "permissiongroups" URL_UPDATE_ANNOTATION_CLASS = "classes/{class_id}" @@ -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_USER_PERMISSIONS, method="post", params=params, data={ @@ -599,6 +599,98 @@ def edit_project_user_permissions( return affected + def edit_team_user_permissions( + self, + contributor_ids: list[int], + permission_ids: list[int], + operation: Literal["grant", "revoke"], + chunk_size=100, + ) -> dict: + from lib.infrastructure.utils import divide_to_chunks + + params = { + "entity": CustomFieldEntityEnum.CONTRIBUTOR.value, + "parentEntity": CustomFieldEntityEnum.TEAM.value, + "action": "editpermissions", + } + op_key = "add" if operation == "grant" else "remove" + + affected: dict = {"add": [], "remove": []} + + for chunk in divide_to_chunks(contributor_ids, chunk_size): + body_query = EmptyQuery() + body_query &= Filter("id", chunk, OperatorEnum.IN) + response = self.client.request( + url=self.URL_EDIT_USER_PERMISSIONS, + method="post", + params=params, + data={ + **body_query.body_builder(), + "body": { + op_key: {"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 {} + affected["add"].extend(data.get("add") or []) + affected["remove"].extend(data.get("remove") or []) + + return affected + + def set_team_user_permissions( + self, + contributor_ids: list[int], + permission_ids: list[int], + chunk_size=100, + ) -> dict: + """Replace a team user's permissions with exactly ``permission_ids``. + + Unlike :meth:`edit_team_user_permissions` (which applies grant/revoke + deltas and honours the backend rule that blocks revoking contributor + permissions while "Manage Contributors' permissions" is enabled), this + performs a full ``setpermissions`` replace. Passing an empty list + clears every permission, including that master permission, so it is the + only way to reset a user back to a clean state. + """ + from lib.infrastructure.utils import divide_to_chunks + + params = { + "entity": CustomFieldEntityEnum.CONTRIBUTOR.value, + "parentEntity": CustomFieldEntityEnum.TEAM.value, + "action": "setpermissions", + } + + result: list = [] + for chunk in divide_to_chunks(contributor_ids, chunk_size): + body_query = EmptyQuery() + body_query &= Filter("id", chunk, OperatorEnum.IN) + response = self.client.request( + url=self.URL_EDIT_USER_PERMISSIONS, + method="post", + params=params, + data={ + **body_query.body_builder(), + "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() + result.extend(response.data.get("data") or []) + + return {"data": result} + 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 9b44a16c..6295f882 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -276,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) @@ -314,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( @@ -372,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/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py new file mode 100644 index 00000000..6d7e15b7 --- /dev/null +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -0,0 +1,330 @@ +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" + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + users = sa.list_users() + contributors = [ + u + for u in users + if u["role"] == "Contributor" and u["state"] == "Confirmed" + ] + if not contributors: + raise RuntimeError( + "No confirmed contributor available for team-user permission tests." + ) + cls.scapegoat = contributors[0] + # Reset to the zero-permission baseline; each test then grants only the + # permissions it needs. + 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 the zero-permission baseline. A plain revoke + # cannot remove "Manage Contributors' permissions" (the backend blocks + # revoking contributor permissions while the master is enabled), so use + # the full "setpermissions" replace with an empty set, which clears + # every permission including the master. + contributor_id = sa.list_users(email=email)[0]["id"] + sa.controller.service_provider.work_management.set_team_user_permissions( + contributor_ids=[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) + + 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] + ) + + 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] + ) + + def test_grant_all_permissions_wildcard(self): + # "*" grants every permission available for the contributor role, + # including the "Manage Contributors' permissions" master. The + # scapegoat starts clean (setUpClass / tearDown) so no separate user + # is needed; tearDown resets it to the zero-permission baseline. + 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] + for key in ( + "Manage Contributors", + "Invite Contributors to team", + "Remove Contributors from team", + "Access Workload management", + ): + self.assertIn(key, line) + granted = { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + self.assertEqual(len(granted), 7) + 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_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_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. The scapegoat starts clean and tearDown + # resets it to the zero-permission baseline, so no separate user is + # needed. + 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] + for key in ( + "Manage Contributors", + "Invite Contributors to team", + "Remove Contributors from team", + "View Contributors", + "Edit Contributors", + "Access Workload management", + ): + self.assertIn(key, line) + granted = { + p["name"] + for p in (sa.list_users(email=email)[0].get("user_permissions") or []) + } + self.assertEqual(len(granted), 7) + + def test_revoke_blocked_while_manage_enabled(self): + # While "Manage Contributors' permissions" is enabled, other + # contributor permissions cannot be revoked. Establish that + # precondition on the clean scapegoat by granting the master (which + # cascades to every contributor permission); tearDown resets it to the + # zero-permission baseline. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], + user=email, + ) + self.assertTrue( + self._has_master( + sa.list_users(email=email)[0].get("user_permissions") or [] + ), + "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) + self.assertIn( + "If Manage Contributors' permissions is granted, it must be " + "revoked before", + joined, + ) + + 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}", + ) 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 00000000..ee63b659 --- /dev/null +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -0,0 +1,380 @@ +"""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-27) + 26 View SDK Token + 27 Access Orchestrate +""" +import base64 +import json +from unittest import TestCase +from unittest.mock import MagicMock + +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", +} +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): + self.id = id_ + self.role = role + self.email = email + + +class _FakeWorkManagementService: + """Models the permissions endpoint: only permissions whose state actually + changes are echoed back under ``userPermissions`` (mirrors the real API, + which silently ignores permissions already in the requested state).""" + + def __init__(self, granted): + self.granted = set(granted) + self.calls = [] + + def edit_team_user_permissions( + self, contributor_ids, permission_ids, operation, chunk_size=100 + ): + self.calls.append((list(contributor_ids), list(permission_ids), operation)) + contributor_id = contributor_ids[0] + affected = [] + for pid in permission_ids: + if operation == "grant" and pid not in self.granted: + self.granted.add(pid) + affected.append(pid) + elif operation == "revoke" and pid in self.granted: + self.granted.discard(pid) + affected.append(pid) + entry = { + "id": contributor_id, + "userPermissions": [{"id": pid} for pid in affected], + } + section = "add" if operation == "grant" else "remove" + return {"add": [], "remove": [], section: [entry]} + + def set_team_user_permissions( + self, contributor_ids, permission_ids, chunk_size=100 + ): + # Full replace: the resulting permission set is exactly permission_ids + # (an empty list clears everything, including the master). + self.calls.append((list(contributor_ids), list(permission_ids), "set")) + self.granted = set(permission_ids) + return {"data": [{"id": pid} for pid in permission_ids]} + + +class _FakeServiceProvider: + def __init__(self, granted=()): + self.work_management = _FakeWorkManagementService(granted) + + def get_team_user_permission_id_name_map(self): + return dict(ALL_PERMS) + + def get_team_user_permission_groups(self): + return {name: dict(perms) for name, perms in GROUPS.items()} + + def get_team_user_permission_id(self, name): + target = _normalize(name) + for pid, pname in ALL_PERMS.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, + ): + reporter = Reporter() + service_provider = _FakeServiceProvider(granted=granted) + team_user = _FakeTeamUser(id_=101, role=role, email=self.EMAIL) + 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")) + self.assertEqual(sp.work_management.calls, [([101], [20], "grant")]) + + def test_grant_already_granted_logs_failure(self): + _, reporter, _ = 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, + ) + + def test_revoke_single_permission_success(self): + _, reporter, _ = 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}.", + ) + + def test_revoke_already_revoked_logs_failure(self): + _, reporter, _ = 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, + ) + + # ---- cascades ------------------------------------------------------ + + def test_grant_master_cascades_all_contributor_permissions(self): + _, reporter, sp = self._run( + ["Manage Contributors' permissions"], "grant" + ) + # backend receives the master first, then every dependent permission + self.assertEqual( + sp.work_management.calls, + [([101], [19, 20, 21, 22, 23, 24, 25], "grant")], + ) + 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_edit_custom_fields_cascades_view(self): + _, reporter, sp = self._run( + ["Edit Contributors' custom field values"], "grant" + ) + self.assertEqual( + sp.work_management.calls, [([101], [24, 23], "grant")] + ) + 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}, + ) + self.assertEqual( + sp.work_management.calls, [([101], [23, 24], "revoke")] + ) + 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()) + + # ---- "*" 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.assertEqual(sent[0], 19, "master permission must be sent first") + 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] + self.assertEqual(set(sent), set(ADMIN_PERMS)) + self.assertFalse(set(sent) & set(CONTRIBUTOR_PERMS)) + success = self._message(reporter, "Successfully granted") + self.assertIn("View SDK Token", success) + self.assertIn("Access Orchestrate", success) + + # ---- 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, _ = self._run( + ["Invite Contributors to team", "NonExistentPermission"], "grant" + ) + 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], "grant")]) + 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], "grant")]) + 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], "grant")]) + + # ---- 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 TestSetTeamUserPermissionsPayload(TestCase): + """The zero-point reset used by integration setup/teardown must issue a + full ``setpermissions`` replace (not a grant/revoke delta).""" + + def _service(self): + from src.superannotate.lib.infrastructure.services.work_management import ( + WorkManagementService, + ) + + client = MagicMock() + client.team_id = 6085 + response = MagicMock() + response.data = {"data": []} + client.request.return_value = response + return WorkManagementService(client), client + + def test_reset_sends_setpermissions_replace_with_empty_set(self): + service, client = self._service() + service.set_team_user_permissions(contributor_ids=[101], permission_ids=[]) + _, kwargs = client.request.call_args + self.assertEqual(kwargs["params"]["action"], "setpermissions") + self.assertEqual(kwargs["params"]["entity"], "Contributor") + self.assertEqual(kwargs["params"]["parentEntity"], "Team") + self.assertEqual(kwargs["data"]["body"], {"userPermissions": []}) + # context header carries the team id + ctx = json.loads(base64.b64decode(kwargs["headers"]["x-sa-entity-context"])) + self.assertEqual(ctx["team_id"], 6085) + + def test_set_sends_exact_permission_ids(self): + service, client = self._service() + service.set_team_user_permissions( + contributor_ids=[101], permission_ids=[20, 22] + ) + _, kwargs = client.request.call_args + self.assertEqual( + kwargs["data"]["body"], + {"userPermissions": [{"id": 20}, {"id": 22}]}, + ) + + +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" + ) From 1d7e06d4cc968aaefd810c1cdfb704b1233db847 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 22 Jul 2026 11:43:58 +0400 Subject: [PATCH 04/13] Enforce team-user permission role mismatch client-side (FRIDAY-5409) Drop the test-only set_team_user_permissions (setpermissions full-replace) helper from the service layer and reset contributors via the edit_team_user_permissions delta endpoint instead. Master-granting integration tests now pick their own disposable contributor and skip when no clean one remains, since the master permission is irreversible via the permissions API. Enforce role validity in UpdateUserPermissionUseCase: a permission that exists but is not allowed for the user's role (e.g. an admin permission requested for a contributor, or vice versa) is dropped client-side and reported as a failure with the "User role does not allow ..." reason, instead of being sent to the backend and poisoning its all-or-nothing batch. Add unit and integration tests covering both role-mismatch directions and the mixed valid + role-invalid case, plus a dedicated team-admin permission integration test suite. Co-authored-by: Cursor --- .../lib/core/serviceproviders.py | 9 - .../lib/core/usecases/work_management.py | 34 ++- .../services/work_management.py | 50 +--- .../test_team_admin_user_permissions.py | 254 ++++++++++++++++++ .../test_team_user_permissions.py | 163 ++++++++--- .../test_team_user_permissions_usecase.py | 101 ++++--- 6 files changed, 456 insertions(+), 155 deletions(-) create mode 100644 tests/integration/work_management/test_team_admin_user_permissions.py diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 25da8b31..7c2feb19 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -270,15 +270,6 @@ def edit_team_user_permissions( ) -> dict: raise NotImplementedError - @abstractmethod - def set_team_user_permissions( - self, - contributor_ids: list[int], - permission_ids: list[int], - chunk_size=100, - ) -> dict: - raise NotImplementedError - @abstractmethod def update_annotation_class( self, diff --git a/src/superannotate/lib/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py index bc41080a..f25d510f 100644 --- a/src/superannotate/lib/core/usecases/work_management.py +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -62,7 +62,7 @@ def execute(self) -> Response: name_by_id = self._service_provider.get_team_user_permission_id_name_map() groups = self._groups() - resolved_ids, unresolved_names = self._resolve_permissions( + resolved_ids, unresolved_names, role_mismatch_names = self._resolve_permissions( team_user.role, name_by_id, groups ) @@ -73,7 +73,13 @@ def execute(self) -> Response: if ordered_ids: affected_ids = self._apply(team_user.id, ordered_ids) - self._log(ordered_ids, affected_ids, unresolved_names, team_user.email) + self._log( + ordered_ids, + affected_ids, + unresolved_names, + role_mismatch_names, + team_user.email, + ) return self._response def _groups(self) -> dict[str, dict[int, str]] | None: @@ -106,29 +112,41 @@ def _resolve_permissions( role: WMUserTypeEnum, name_by_id: dict[int, str], groups: dict[str, dict[int, str]] | None, - ) -> tuple[list[int], list[str]]: + ) -> 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 == "*": - return list( - self._role_team_user_permission_map(role, name_by_id, groups).keys() - ), [] + return list(role_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 + return resolved_ids, unresolved_names, role_mismatch_names def _log( self, ordered_ids: list[int], affected_ids: set[int], unresolved_names: list[str], + role_mismatch_names: list[str], user_email: str, ) -> None: name_by_id = self._service_provider.get_team_user_permission_id_name_map() @@ -137,7 +155,7 @@ def _log( ] failed_names = [ name_by_id[pid] for pid in ordered_ids if pid not in affected_ids - ] + unresolved_names + ] + role_mismatch_names + unresolved_names verb_inf = "grant" if self._operation == "grant" else "revoke" verb_past = "granted" if self._operation == "grant" else "revoked" diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 99e04ff9..6b9e5799 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -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, @@ -643,54 +643,6 @@ def edit_team_user_permissions( return affected - def set_team_user_permissions( - self, - contributor_ids: list[int], - permission_ids: list[int], - chunk_size=100, - ) -> dict: - """Replace a team user's permissions with exactly ``permission_ids``. - - Unlike :meth:`edit_team_user_permissions` (which applies grant/revoke - deltas and honours the backend rule that blocks revoking contributor - permissions while "Manage Contributors' permissions" is enabled), this - performs a full ``setpermissions`` replace. Passing an empty list - clears every permission, including that master permission, so it is the - only way to reset a user back to a clean state. - """ - from lib.infrastructure.utils import divide_to_chunks - - params = { - "entity": CustomFieldEntityEnum.CONTRIBUTOR.value, - "parentEntity": CustomFieldEntityEnum.TEAM.value, - "action": "setpermissions", - } - - result: list = [] - for chunk in divide_to_chunks(contributor_ids, chunk_size): - body_query = EmptyQuery() - body_query &= Filter("id", chunk, OperatorEnum.IN) - response = self.client.request( - url=self.URL_EDIT_USER_PERMISSIONS, - method="post", - params=params, - data={ - **body_query.body_builder(), - "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() - result.extend(response.data.get("data") or []) - - return {"data": result} - def update_annotation_class( self, project_id: int, 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 00000000..94cb4ee9 --- /dev/null +++ b/tests/integration/work_management/test_team_admin_user_permissions.py @@ -0,0 +1,254 @@ +from unittest import TestCase + +from lib.core.exceptions import AppException +from src.superannotate import SAClient + +sa = SAClient() + + +class TestTeamAdminUserPermissions(TestCase): + # Team-admin permissions (ids 26, 27) have no apostrophes, so exact log + # assertions are stable. They are reversible via the permissions API (no + # irrevocable master like the contributor "Manage Contributors' permissions"). + PERMISSION = "View SDK Token" + OTHER_PERMISSION = "Access Orchestrate" + # A contributor-only permission; granting it to an admin must be rejected. + CONTRIBUTOR_PERMISSION = "Invite Contributors to team" + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + cls.scapegoat = cls._find_admin(clean=True) + cls._cleanup() + + @classmethod + def tearDownClass(cls) -> None: + cls._cleanup() + + @classmethod + def _find_admin(cls, clean: bool = False): + users = sa.list_users() + admins = [ + u + for u in users + if u.get("state") == "Confirmed" and u.get("role") in ("TeamAdmin", "TeamOwner") + ] + if not clean: + return admins[0] + for u in admins: + full = sa.list_users(email=u["email"])[0] + if not (full.get("user_permissions") or []): + return u + return admins[0] + + @classmethod + def _cleanup(cls): + # Admin permissions are reversible, so revoking each one individually + # reliably restores a clean state. + for name in (cls.OTHER_PERMISSION, cls.PERMISSION): + try: + sa.revoke_team_user_permissions( + permissions=[name], + 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_all_permissions_wildcard(self): + # "*" resolves to the admin role's permissions (View SDK Token + + # Access Orchestrate). Unlike the contributor wildcard, this is fully + # reversible, so it can be exercised idempotently. + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions="*", + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + self.assertIn( + f"Successfully granted [{self.PERMISSION}, {self.OTHER_PERMISSION}] " + f"permission(s) for user: {self.scapegoat['email']}.", + joined, + ) + granted = { + p["name"] + for p in ( + sa.list_users(email=self.scapegoat["email"])[0].get( + "user_permissions" + ) + or [] + ) + } + self.assertEqual(granted, {self.PERMISSION, self.OTHER_PERMISSION}) + + 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, + ) + + 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_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, + ) + + 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", + ) diff --git a/tests/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py index 6d7e15b7..b72b9eb5 100644 --- a/tests/integration/work_management/test_team_user_permissions.py +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -1,5 +1,6 @@ from unittest import TestCase +from lib.core import TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS from lib.core.exceptions import AppException from src.superannotate import SAClient @@ -12,22 +13,19 @@ class TestTeamUserPermissions(TestCase): 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 = "View SDK Token" @classmethod def setUpClass(cls, *args, **kwargs) -> None: - users = sa.list_users() - contributors = [ - u - for u in users - if u["role"] == "Contributor" and u["state"] == "Confirmed" - ] - if not contributors: + # Scapegoat for the per-permission tests: a contributor without the + # "Manage Contributors' permissions" master, kept clean by _reset(). + cls.scapegoat = cls._find_contributor_without_master() + if cls.scapegoat is None: raise RuntimeError( - "No confirmed contributor available for team-user permission tests." + "No contributor without 'Manage Contributors' permissions " + "available for team-user permission tests." ) - cls.scapegoat = contributors[0] - # Reset to the zero-permission baseline; each test then grants only the - # permissions it needs. cls._reset(cls.scapegoat["email"]) @classmethod @@ -36,16 +34,26 @@ def tearDownClass(cls) -> None: @classmethod def _reset(cls, email): - # Reset a team user to the zero-permission baseline. A plain revoke - # cannot remove "Manage Contributors' permissions" (the backend blocks - # revoking contributor permissions while the master is enabled), so use - # the full "setpermissions" replace with an empty set, which clears - # every permission including the master. + # Reset a team user by revoking every permission individually via the + # grant/revoke delta endpoint. "Manage Contributors' permissions" + # (id 19) cannot be revoked this way (the backend blocks revoking + # contributor permissions while the master is enabled), so it is + # skipped; the per-permission tests never grant it, and the + # master-granting tests run on a separate, disposable contributor. contributor_id = sa.list_users(email=email)[0]["id"] - sa.controller.service_provider.work_management.set_team_user_permissions( - contributor_ids=[contributor_id], - permission_ids=[], - ) + name_by_id = sa.controller.service_provider.get_team_user_permission_id_name_map() + master_id = TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS["id"] + for pid in name_by_id: + if pid == master_id: + continue + try: + sa.controller.service_provider.work_management.edit_team_user_permissions( + contributor_ids=[contributor_id], + permission_ids=[pid], + operation="revoke", + ) + except Exception: + pass def tearDown(self): self._reset(self.scapegoat["email"]) @@ -54,6 +62,28 @@ def tearDown(self): def _has_master(perms): return any("Manage Contributors" in (p.get("name") or "") for p in perms) + @classmethod + def _find_contributor_without_master(cls, exclude_email=None): + for u in sa.list_users(): + if u.get("role") != "Contributor" or 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 + + @classmethod + def _find_contributor_with_master(cls): + for u in sa.list_users(): + if u.get("role") != "Contributor" or u.get("state") != "Confirmed": + continue + full = sa.list_users(email=u["email"])[0] + if cls._has_master(full.get("user_permissions") or []): + return u + return None + def test_grant_permission_by_email(self): with self.assertLogs("sa", level="INFO") as cm: sa.grant_team_user_permissions( @@ -79,10 +109,19 @@ def test_grant_permission_by_user_id(self): def test_grant_all_permissions_wildcard(self): # "*" grants every permission available for the contributor role, - # including the "Manage Contributors' permissions" master. The - # scapegoat starts clean (setUpClass / tearDown) so no separate user - # is needed; tearDown resets it to the zero-permission baseline. - email = self.scapegoat["email"] + # including the "Manage Contributors' permissions" master, which is + # irreversible via the permissions API. Run it on a disposable + # contributor that does not yet have the master (never the main + # scapegoat); skip if none is available. + target = self._find_contributor_without_master( + exclude_email=self.scapegoat["email"] + ) + if target is None: + self.skipTest( + "No contributor without 'Manage Contributors' permissions " + "available; wildcard grant is irreversible." + ) + email = target["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 [")] @@ -167,6 +206,38 @@ def test_grant_invalid_permission_logs_failure(self): ) 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( @@ -228,10 +299,17 @@ def test_revoke_unknown_user_raises(self): def test_grant_manage_contributors_permissions_cascade(self): # Granting "Manage Contributors' permissions" must cascade to all - # contributor permissions. The scapegoat starts clean and tearDown - # resets it to the zero-permission baseline, so no separate user is - # needed. - email = self.scapegoat["email"] + # contributor permissions. The master is irreversible, so run on a + # disposable contributor that does not yet have it. + target = self._find_contributor_without_master( + exclude_email=self.scapegoat["email"] + ) + if target is None: + self.skipTest( + "No contributor without 'Manage Contributors' permissions " + "available; cascade grant is irreversible." + ) + email = target["email"] with self.assertLogs("sa", level="INFO") as cm: sa.grant_team_user_permissions( permissions=["Manage Contributors' permissions"], @@ -257,15 +335,26 @@ def test_grant_manage_contributors_permissions_cascade(self): def test_revoke_blocked_while_manage_enabled(self): # While "Manage Contributors' permissions" is enabled, other - # contributor permissions cannot be revoked. Establish that - # precondition on the clean scapegoat by granting the master (which - # cascades to every contributor permission); tearDown resets it to the - # zero-permission baseline. - email = self.scapegoat["email"] - sa.grant_team_user_permissions( - permissions=["Manage Contributors' permissions"], - user=email, - ) + # contributor permissions cannot be revoked. Prefer reusing a + # contributor that already has the master (irreversible, so it stays + # enabled between runs); otherwise grant it on a disposable one. + target = self._find_contributor_with_master() + if target is None: + target = self._find_contributor_without_master( + exclude_email=self.scapegoat["email"] + ) + if target is None: + self.skipTest( + "No contributor available to verify the revoke block." + ) + email = target["email"] + if not self._has_master( + sa.list_users(email=email)[0].get("user_permissions") or [] + ): + sa.grant_team_user_permissions( + permissions=["Manage Contributors' permissions"], + user=email, + ) self.assertTrue( self._has_master( sa.list_users(email=email)[0].get("user_permissions") or [] diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py index ee63b659..dfce2474 100644 --- a/tests/unit/test_team_user_permissions_usecase.py +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -18,10 +18,7 @@ 26 View SDK Token 27 Access Orchestrate """ -import base64 -import json from unittest import TestCase -from unittest.mock import MagicMock from src.superannotate.lib.core.entities.work_managament import WMUserTypeEnum from src.superannotate.lib.core.reporter import Reporter @@ -90,15 +87,6 @@ def edit_team_user_permissions( section = "add" if operation == "grant" else "remove" return {"add": [], "remove": [], section: [entry]} - def set_team_user_permissions( - self, contributor_ids, permission_ids, chunk_size=100 - ): - # Full replace: the resulting permission set is exactly permission_ids - # (an empty list clears everything, including the master). - self.calls.append((list(contributor_ids), list(permission_ids), "set")) - self.granted = set(permission_ids) - return {"data": [{"id": pid} for pid in permission_ids]} - class _FakeServiceProvider: def __init__(self, granted=()): @@ -266,6 +254,55 @@ def test_wildcard_admin_role_grants_only_admin_permissions(self): self.assertIn("View SDK Token", success) self.assertIn("Access Orchestrate", success) + # ---- 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 not sent, so it cannot poison the + # backend's all-or-nothing batch). + _, reporter, sp = self._run( + ["Invite Contributors to team", "View SDK Token"], "grant" + ) + self.assertEqual(sp.work_management.calls, [([101], [20], "grant")]) + 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): @@ -327,46 +364,6 @@ def test_unknown_user_returns_error(self): self.assertEqual(sp.work_management.calls, []) -class TestSetTeamUserPermissionsPayload(TestCase): - """The zero-point reset used by integration setup/teardown must issue a - full ``setpermissions`` replace (not a grant/revoke delta).""" - - def _service(self): - from src.superannotate.lib.infrastructure.services.work_management import ( - WorkManagementService, - ) - - client = MagicMock() - client.team_id = 6085 - response = MagicMock() - response.data = {"data": []} - client.request.return_value = response - return WorkManagementService(client), client - - def test_reset_sends_setpermissions_replace_with_empty_set(self): - service, client = self._service() - service.set_team_user_permissions(contributor_ids=[101], permission_ids=[]) - _, kwargs = client.request.call_args - self.assertEqual(kwargs["params"]["action"], "setpermissions") - self.assertEqual(kwargs["params"]["entity"], "Contributor") - self.assertEqual(kwargs["params"]["parentEntity"], "Team") - self.assertEqual(kwargs["data"]["body"], {"userPermissions": []}) - # context header carries the team id - ctx = json.loads(base64.b64decode(kwargs["headers"]["x-sa-entity-context"])) - self.assertEqual(ctx["team_id"], 6085) - - def test_set_sends_exact_permission_ids(self): - service, client = self._service() - service.set_team_user_permissions( - contributor_ids=[101], permission_ids=[20, 22] - ) - _, kwargs = client.request.call_args - self.assertEqual( - kwargs["data"]["body"], - {"userPermissions": [{"id": 20}, {"id": 22}]}, - ) - - class TestUserPermissionNameNormalization(TestCase): def test_normalizes_curly_apostrophe_and_case(self): self.assertEqual( From 4e54ef59ccf90840222055670b48dbb9ed881b47 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 22 Jul 2026 17:40:21 +0400 Subject: [PATCH 05/13] Derive team-user master permission cascade from live group data (FRIDAY-5409) Stop hardcoding the "Manage Contributors' permissions" (id 19) grant cascade as [20, 21, 22, 23, 24, 25]. The set of contributor permissions can vary per team (e.g. id 25 may be absent depending on configuration), so derive the master's cascade at runtime from the /permissiongroups response: it grants every other permission present in the master's group. The name-based cascades (Edit -> View custom field values, and the reverse revoke) remain constant since they are not derivable from group membership. Update the master/wildcard integration assertions to compare against the live contributor permission set instead of a hardcoded count of 7, and add a unit test verifying the cascade adapts when a permission (id 25) is absent. Co-authored-by: Cursor --- src/superannotate/lib/core/__init__.py | 14 +++--- .../lib/core/usecases/work_management.py | 35 ++++++++++++--- .../test_team_user_permissions.py | 15 ++++++- .../test_team_user_permissions_usecase.py | 45 ++++++++++++++++--- 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index c36c6b31..9ad214c3 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -160,15 +160,17 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): "id": 19, "name": "Manage Contributors’ permissions", } -# Granting "Manage Contributors' permissions" grants every contributor -# permission; granting "Edit Contributors' custom field values" also grants -# "View Contributors' custom field values". +# Granting "Edit Contributors' custom field values" also grants "View +# Contributors' custom field values". The "Manage Contributors' permissions" +# master cascade (granting it grants every other permission in its group) is +# NOT hardcoded here: it is derived at runtime from the live permission-groups +# data so it only includes 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 = { - 19: [20, 21, 22, 23, 24, 25], 24: [23], } -# Revoking "View Contributors' custom field values" also revokes -# "Edit Contributors' custom field values". +# Revoking "View Contributors' custom field values" also revokes "Edit +# Contributors' custom field values". TEAM_USER_PERMISSION_REVOKE_CASCADE = { 23: [24], } diff --git a/src/superannotate/lib/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py index f25d510f..5b6eed67 100644 --- a/src/superannotate/lib/core/usecases/work_management.py +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -67,8 +67,9 @@ def execute(self) -> Response: ) affected_ids: set[int] = set() + cascade = self._build_cascade(self._operation, groups) ordered_ids = self._order_team_permission_ids( - self._cascade_team_permission_ids(resolved_ids, self._operation) + self._cascade_team_permission_ids(resolved_ids, cascade) ) if ordered_ids: affected_ids = self._apply(team_user.id, ordered_ids) @@ -200,16 +201,36 @@ def _role_team_user_permission_map( return dict(perms) return dict(full_map) - @staticmethod - def _cascade_team_permission_ids( - requested: list[int], operation: PermissionOperation - ) -> list[int]: - """Expand requested permission ids with cascade dependents (by id).""" - cascade = ( + def _build_cascade( + self, + operation: PermissionOperation, + groups: dict[str, dict[int, str]] | None, + ) -> dict[int, list[int]]: + # Start from the hardcoded name-based cascades (e.g. Edit -> View + # custom field values), then derive the "Manage Contributors' + # permissions" master cascade from the live permission-groups data: + # granting the 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). + base = ( constants.TEAM_USER_PERMISSION_GRANT_CASCADE if operation == "grant" else constants.TEAM_USER_PERMISSION_REVOKE_CASCADE ) + cascade = {pid: list(deps) for pid, deps in base.items()} + if operation == "grant" and groups: + master_id = MANAGE_CONTRIBUTORS_ID + for perms in groups.values(): + if master_id in perms: + cascade[master_id] = [pid for pid in perms if pid != master_id] + break + 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): diff --git a/tests/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py index b72b9eb5..49e5636b 100644 --- a/tests/integration/work_management/test_team_user_permissions.py +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -62,6 +62,17 @@ def tearDown(self): 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): for u in sa.list_users(): @@ -138,7 +149,7 @@ def test_grant_all_permissions_wildcard(self): p["name"] for p in (sa.list_users(email=email)[0].get("user_permissions") or []) } - self.assertEqual(len(granted), 7) + 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): @@ -331,7 +342,7 @@ def test_grant_manage_contributors_permissions_cascade(self): p["name"] for p in (sa.list_users(email=email)[0].get("user_permissions") or []) } - self.assertEqual(len(granted), 7) + self.assertEqual(granted, self._contributor_permission_names()) def test_revoke_blocked_while_manage_enabled(self): # While "Manage Contributors' permissions" is enabled, other diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py index dfce2474..bd849308 100644 --- a/tests/unit/test_team_user_permissions_usecase.py +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -89,18 +89,20 @@ def edit_team_user_permissions( class _FakeServiceProvider: - def __init__(self, granted=()): + def __init__(self, granted=(), groups=None, name_by_id=None): self.work_management = _FakeWorkManagementService(granted) + 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(ALL_PERMS) + return dict(self._name_by_id) def get_team_user_permission_groups(self): - return {name: dict(perms) for name, perms in GROUPS.items()} + 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 ALL_PERMS.items(): + for pid, pname in self._name_by_id.items(): if _normalize(pname) == target: return pid return None @@ -116,9 +118,13 @@ def _run( granted=(), role=WMUserTypeEnum.Contributor, user=None, + groups=None, + name_by_id=None, ): reporter = Reporter() - service_provider = _FakeServiceProvider(granted=granted) + service_provider = _FakeServiceProvider( + granted=granted, groups=groups, name_by_id=name_by_id + ) team_user = _FakeTeamUser(id_=101, role=role, email=self.EMAIL) resolver = (lambda _: [team_user]) if user is not False else (lambda _: []) use_case = UpdateUserPermissionUseCase( @@ -211,6 +217,35 @@ def test_grant_master_cascades_all_contributor_permissions(self): 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], "grant")], + ) + 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" From 77e97b7d3b96204ce7b29246141785426ed30edb Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 23 Jul 2026 15:17:41 +0400 Subject: [PATCH 06/13] update set user permissions enpoint --- .../lib/core/serviceproviders.py | 8 +- .../lib/core/usecases/work_management.py | 136 ++++--- .../services/work_management.py | 75 ++-- .../test_team_admin_user_permissions.py | 36 +- .../test_team_user_permissions.py | 338 ++++++++++++------ .../test_team_user_permissions_usecase.py | 194 ++++++---- 6 files changed, 524 insertions(+), 263 deletions(-) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 7c2feb19..80712b64 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -261,13 +261,11 @@ def edit_project_user_permissions( raise NotImplementedError @abstractmethod - def edit_team_user_permissions( + def set_team_user_permissions( self, - contributor_ids: list[int], + contributor_id: int, permission_ids: list[int], - operation: Literal["grant", "revoke"], - chunk_size=100, - ) -> dict: + ) -> list[int]: raise NotImplementedError @abstractmethod diff --git a/src/superannotate/lib/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py index 5b6eed67..b21f01c9 100644 --- a/src/superannotate/lib/core/usecases/work_management.py +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable from typing import Literal import lib.core as constants @@ -19,16 +19,22 @@ class UpdateUserPermissionUseCase(BaseReportableUseCase): """Grant or revoke team-user permissions for a single user. - Encapsulates the business rules that the work-management permissions API - does not enforce on its own: + 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 - (the backend rejects the whole batch otherwise); + (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 through the permissions API; + auto-cascade; + - the "Manage Contributors' permissions" master implies every other + permission in its group: whenever it stays in the desired set we add + the rest (this also preserves the rule that members cannot be revoked + while the master is enabled); - per-permission success / failure is reported through the reporter. """ @@ -61,22 +67,31 @@ def execute(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 + team_user.role, name_by_id, groups, current_ids ) - affected_ids: set[int] = set() + # The permissions we attempted to change (requested + cascade), used for + # per-permission success / failure reporting. cascade = self._build_cascade(self._operation, groups) - ordered_ids = self._order_team_permission_ids( - self._cascade_team_permission_ids(resolved_ids, cascade) - ) - if ordered_ids: - affected_ids = self._apply(team_user.id, ordered_ids) + attempted_ids = self._cascade_team_permission_ids(resolved_ids, cascade) + + desired_ids = self._desired_permission_ids(current_ids, attempted_ids, groups) + + # 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( - ordered_ids, - affected_ids, + current_ids, + new_state, + attempted_ids, unresolved_names, role_mismatch_names, team_user.email, @@ -89,30 +104,52 @@ def _groups(self) -> dict[str, dict[int, str]] | None: except Exception: return None + def _desired_permission_ids( + self, + current_ids: list[int], + attempted_ids: list[int], + groups: dict[str, 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, groups) + return sorted(desired) + + @staticmethod + def _apply_master_invariant( + desired: set[int], groups: dict[str, dict[int, str]] | None + ) -> set[int]: + if MANAGE_CONTRIBUTORS_ID not in desired or not groups: + return desired + for perms in groups.values(): + if MANAGE_CONTRIBUTORS_ID in perms: + return desired | set(perms.keys()) + return desired + def _apply(self, contributor_id: int, permission_ids: list[int]) -> set[int]: - response = self._service_provider.work_management.edit_team_user_permissions( - contributor_ids=[contributor_id], - permission_ids=permission_ids, - operation=self._operation, - ) - section_key = "add" if self._operation == "grant" else "remove" - entry = next( - ( - c - for c in (response.get(section_key) or []) - if c.get("id") == contributor_id - ), - None, + return set( + self._service_provider.work_management.set_team_user_permissions( + contributor_id=contributor_id, + permission_ids=permission_ids, + ) ) - if not entry: - return set() - return {p["id"] for p in (entry.get("userPermissions") or [])} 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 @@ -121,6 +158,11 @@ def _resolve_permissions( 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. + return [pid for pid in current_perm_ids if pid in role_ids], [], [] return list(role_ids), [], [] resolved_ids: list[int] = [] @@ -144,19 +186,27 @@ def _resolve_permissions( def _log( self, - ordered_ids: list[int], - affected_ids: set[int], + current_ids: list[int], + new_state: set[int], + attempted_ids: list[int], unresolved_names: list[str], role_mismatch_names: list[str], user_email: str, ) -> None: name_by_id = self._service_provider.get_team_user_permission_id_name_map() - succeeded_names = [ - name_by_id[pid] for pid in ordered_ids if pid in affected_ids - ] - failed_names = [ - name_by_id[pid] for pid in ordered_ids if pid not in affected_ids - ] + role_mismatch_names + unresolved_names + 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 + + succeeded_names = [name_by_id[pid] for pid in attempted_ids if pid in changed] + failed_names = ( + [name_by_id[pid] for pid in attempted_ids if pid not in changed] + + role_mismatch_names + + unresolved_names + ) verb_inf = "grant" if self._operation == "grant" else "revoke" verb_past = "granted" if self._operation == "grant" else "revoked" @@ -239,13 +289,3 @@ def _cascade_team_permission_ids( expanded.append(dep_id) seen.add(dep_id) return expanded - - @staticmethod - def _order_team_permission_ids(perm_ids: list[int]) -> list[int]: - # The master permission auto-grants the other contributor permissions - # and blocks their revocation while enabled, so process it first. - if MANAGE_CONTRIBUTORS_ID in perm_ids: - return [MANAGE_CONTRIBUTORS_ID] + [ - pid for pid in perm_ids if pid != MANAGE_CONTRIBUTORS_ID - ] - return list(perm_ids) diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 6b9e5799..9f331bbf 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -78,7 +78,7 @@ class WorkManagementService(BaseWorkManagementService): URL_SEARCH_PROJECTS = "projects/search" URL_RESUME_PAUSE_USER = "teams/editprojectsusers" URL_CONTRIBUTORS_CATEGORIES = "customentities/edit" - URL_EDIT_USER_PERMISSIONS = "customentities/edit" + URL_SET_TEAM_USER_PERMISSIONS = "teamusers/setpermissions" URL_PERMISSION_GROUPS = "permissiongroups" URL_UPDATE_ANNOTATION_CLASS = "classes/{class_id}" @@ -599,49 +599,40 @@ def edit_project_user_permissions( return affected - def edit_team_user_permissions( + def set_team_user_permissions( self, - contributor_ids: list[int], + contributor_id: int, permission_ids: list[int], - operation: Literal["grant", "revoke"], - chunk_size=100, - ) -> dict: - from lib.infrastructure.utils import divide_to_chunks - - params = { - "entity": CustomFieldEntityEnum.CONTRIBUTOR.value, - "parentEntity": CustomFieldEntityEnum.TEAM.value, - "action": "editpermissions", - } - op_key = "add" if operation == "grant" else "remove" - - affected: dict = {"add": [], "remove": []} - - for chunk in divide_to_chunks(contributor_ids, chunk_size): - body_query = EmptyQuery() - body_query &= Filter("id", chunk, OperatorEnum.IN) - response = self.client.request( - url=self.URL_EDIT_USER_PERMISSIONS, - method="post", - params=params, - data={ - **body_query.body_builder(), - "body": { - op_key: {"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 {} - affected["add"].extend(data.get("add") or []) - affected["remove"].extend(data.get("remove") or []) - - return affected + ) -> 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, diff --git a/tests/integration/work_management/test_team_admin_user_permissions.py b/tests/integration/work_management/test_team_admin_user_permissions.py index 94cb4ee9..90f60885 100644 --- a/tests/integration/work_management/test_team_admin_user_permissions.py +++ b/tests/integration/work_management/test_team_admin_user_permissions.py @@ -30,7 +30,8 @@ def _find_admin(cls, clean: bool = False): admins = [ u for u in users - if u.get("state") == "Confirmed" and u.get("role") in ("TeamAdmin", "TeamOwner") + if u.get("state") == "Confirmed" + and u.get("role") in ("TeamAdmin", "TeamOwner") ] if not clean: return admins[0] @@ -99,9 +100,7 @@ def test_grant_all_permissions_wildcard(self): granted = { p["name"] for p in ( - sa.list_users(email=self.scapegoat["email"])[0].get( - "user_permissions" - ) + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") or [] ) } @@ -144,6 +143,31 @@ def test_revoke_permission(self): 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( @@ -217,9 +241,7 @@ def test_grant_contributor_permission_for_admin_logs_failure(self): granted = { p["name"] for p in ( - sa.list_users(email=self.scapegoat["email"])[0].get( - "user_permissions" - ) + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") or [] ) } diff --git a/tests/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py index 49e5636b..4e43c594 100644 --- a/tests/integration/work_management/test_team_user_permissions.py +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -1,6 +1,5 @@ from unittest import TestCase -from lib.core import TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS from lib.core.exceptions import AppException from src.superannotate import SAClient @@ -15,12 +14,26 @@ class TestTeamUserPermissions(TestCase): CURLY_PERMISSION = "View Contributors’ scores" # An admin-only permission; granting it to a contributor must be rejected. ADMIN_PERMISSION = "View SDK Token" + # 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: a contributor without the - # "Manage Contributors' permissions" master, kept clean by _reset(). + # 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 " @@ -34,26 +47,15 @@ def tearDownClass(cls) -> None: @classmethod def _reset(cls, email): - # Reset a team user by revoking every permission individually via the - # grant/revoke delta endpoint. "Manage Contributors' permissions" - # (id 19) cannot be revoked this way (the backend blocks revoking - # contributor permissions while the master is enabled), so it is - # skipped; the per-permission tests never grant it, and the - # master-granting tests run on a separate, disposable contributor. + # 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"] - name_by_id = sa.controller.service_provider.get_team_user_permission_id_name_map() - master_id = TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS["id"] - for pid in name_by_id: - if pid == master_id: - continue - try: - sa.controller.service_provider.work_management.edit_team_user_permissions( - contributor_ids=[contributor_id], - permission_ids=[pid], - operation="revoke", - ) - except Exception: - pass + sa.controller.service_provider.work_management.set_team_user_permissions( + contributor_id=contributor_id, + permission_ids=[], + ) def tearDown(self): self._reset(self.scapegoat["email"]) @@ -74,9 +76,13 @@ def _contributor_permission_names(): return set() @classmethod - def _find_contributor_without_master(cls, exclude_email=None): + def _find_contributor_without_master( + cls, exclude_email=None, require_confirmed=True + ): for u in sa.list_users(): - if u.get("role") != "Contributor" or u.get("state") != "Confirmed": + if u.get("role") != "Contributor": + continue + if require_confirmed and u.get("state") != "Confirmed": continue if u.get("email") == exclude_email: continue @@ -85,15 +91,27 @@ def _find_contributor_without_master(cls, exclude_email=None): 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 _find_contributor_with_master(cls): - for u in sa.list_users(): - if u.get("role") != "Contributor" or u.get("state") != "Confirmed": - continue - full = sa.list_users(email=u["email"])[0] - if cls._has_master(full.get("user_permissions") or []): - return u - return None + 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: @@ -105,6 +123,7 @@ def test_grant_permission_by_email(self): 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"] @@ -117,25 +136,19 @@ def test_grant_permission_by_user_id(self): 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, which is - # irreversible via the permissions API. Run it on a disposable - # contributor that does not yet have the master (never the main - # scapegoat); skip if none is available. - target = self._find_contributor_without_master( - exclude_email=self.scapegoat["email"] - ) - if target is None: - self.skipTest( - "No contributor without 'Manage Contributors' permissions " - "available; wildcard grant is irreversible." - ) - email = target["email"] + # 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 [")] + 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] for key in ( @@ -145,10 +158,7 @@ def test_grant_all_permissions_wildcard(self): "Access Workload management", ): self.assertIn(key, line) - granted = { - p["name"] - for p in (sa.list_users(email=email)[0].get("user_permissions") or []) - } + granted = self._permission_names(email) self.assertEqual(granted, self._contributor_permission_names()) self.assertTrue(self._has_master([{"name": n} for n in granted])) @@ -169,8 +179,7 @@ def test_grant_already_granted_logs_failure(self): ) assert "Possible reasons:" in joined assert ( - f"User already has [{self.PERMISSION}] permission(s) granted." - in joined + f"User already has [{self.PERMISSION}] permission(s) granted." in joined ) def test_revoke_permission(self): @@ -188,6 +197,32 @@ def test_revoke_permission(self): 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( @@ -241,9 +276,7 @@ def test_grant_admin_permission_for_contributor_logs_failure(self): granted = { p["name"] for p in ( - sa.list_users(email=self.scapegoat["email"])[0].get( - "user_permissions" - ) + sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") or [] ) } @@ -310,23 +343,17 @@ def test_revoke_unknown_user_raises(self): def test_grant_manage_contributors_permissions_cascade(self): # Granting "Manage Contributors' permissions" must cascade to all - # contributor permissions. The master is irreversible, so run on a - # disposable contributor that does not yet have it. - target = self._find_contributor_without_master( - exclude_email=self.scapegoat["email"] - ) - if target is None: - self.skipTest( - "No contributor without 'Manage Contributors' permissions " - "available; cascade grant is irreversible." - ) - email = target["email"] + # 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 [")] + 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] for key in ( @@ -338,38 +365,50 @@ def test_grant_manage_contributors_permissions_cascade(self): "Access Workload management", ): self.assertIn(key, line) - granted = { - p["name"] - for p in (sa.list_users(email=email)[0].get("user_permissions") or []) - } - self.assertEqual(granted, self._contributor_permission_names()) + self.assertEqual( + self._permission_names(email), self._contributor_permission_names() + ) - def test_revoke_blocked_while_manage_enabled(self): - # While "Manage Contributors' permissions" is enabled, other - # contributor permissions cannot be revoked. Prefer reusing a - # contributor that already has the master (irreversible, so it stays - # enabled between runs); otherwise grant it on a disposable one. - target = self._find_contributor_with_master() - if target is None: - target = self._find_contributor_without_master( - exclude_email=self.scapegoat["email"] - ) - if target is None: - self.skipTest( - "No contributor available to verify the revoke block." - ) - email = target["email"] - if not self._has_master( - sa.list_users(email=email)[0].get("user_permissions") or [] - ): - sa.grant_team_user_permissions( - permissions=["Manage Contributors' permissions"], - user=email, + 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._has_master( - sa.list_users(email=email)[0].get("user_permissions") or [] - ), + 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: @@ -386,6 +425,12 @@ def test_revoke_blocked_while_manage_enabled(self): "revoked before", 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 @@ -400,10 +445,15 @@ def test_revoke_view_custom_field_values_cascade(self): ) granted = { p["name"] - for p in (sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") or []) + 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), + 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( @@ -415,16 +465,104 @@ def test_revoke_view_custom_field_values_cascade(self): permissions=["View Contributors' custom field values"], user=self.scapegoat["email"], ) - success = [o for o in cm.output if o.startswith("INFO:sa:Successfully revoked [")] + 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 []) + 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", + ) + 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"View should remain set after granting Edit, got {names}", + ) diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py index bd849308..084cdb54 100644 --- a/tests/unit/test_team_user_permissions_usecase.py +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -17,7 +17,12 @@ Team admin permissions (ids 26-27) 26 View SDK Token 27 Access Orchestrate + +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 @@ -52,40 +57,34 @@ def _normalize(name: str) -> str: class _FakeTeamUser: - def __init__(self, id_: int, role: WMUserTypeEnum, email: str): + 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 permissions endpoint: only permissions whose state actually - changes are echoed back under ``userPermissions`` (mirrors the real API, - which silently ignores permissions already in the requested state).""" + """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).""" def __init__(self, granted): self.granted = set(granted) self.calls = [] - def edit_team_user_permissions( - self, contributor_ids, permission_ids, operation, chunk_size=100 - ): - self.calls.append((list(contributor_ids), list(permission_ids), operation)) - contributor_id = contributor_ids[0] - affected = [] - for pid in permission_ids: - if operation == "grant" and pid not in self.granted: - self.granted.add(pid) - affected.append(pid) - elif operation == "revoke" and pid in self.granted: - self.granted.discard(pid) - affected.append(pid) - entry = { - "id": contributor_id, - "userPermissions": [{"id": pid} for pid in affected], - } - section = "add" if operation == "grant" else "remove" - return {"add": [], "remove": [], section: [entry]} + def set_team_user_permissions(self, contributor_id, permission_ids): + self.calls.append((contributor_id, list(permission_ids))) + self.granted = set(permission_ids) + return list(permission_ids) class _FakeServiceProvider: @@ -120,12 +119,22 @@ def _run( user=None, groups=None, name_by_id=None, + current_perm_ids=None, ): + # 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=granted, groups=groups, name_by_id=name_by_id + granted=current, groups=groups, name_by_id=name_by_id + ) + team_user = _FakeTeamUser( + id_=101, + role=role, + email=self.EMAIL, + user_permissions=current, ) - team_user = _FakeTeamUser(id_=101, role=role, email=self.EMAIL) resolver = (lambda _: [team_user]) if user is not False else (lambda _: []) use_case = UpdateUserPermissionUseCase( reporter=reporter, @@ -148,9 +157,7 @@ def _message(reporter, prefix): # ---- success / failure logging ------------------------------------- def test_grant_single_permission_success(self): - response, reporter, sp = self._run( - ["Invite Contributors to team"], "grant" - ) + response, reporter, sp = self._run(["Invite Contributors to team"], "grant") self.assertFalse(response.errors) self.assertEqual( self._message(reporter, "Successfully granted"), @@ -158,10 +165,12 @@ def test_grant_single_permission_success(self): f"permission(s) for user: {self.EMAIL}.", ) self.assertIsNone(self._message(reporter, "Could not grant")) - self.assertEqual(sp.work_management.calls, [([101], [20], "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, _ = self._run( + _, reporter, sp = self._run( ["Invite Contributors to team"], "grant", granted={20} ) self.assertIsNone(self._message(reporter, "Successfully granted")) @@ -171,9 +180,11 @@ def test_grant_already_granted_logs_failure(self): "User already has [Invite Contributors to team] permission(s) granted.", failure, ) + # Nothing changes -> no network round-trip. + self.assertEqual(sp.work_management.calls, []) def test_revoke_single_permission_success(self): - _, reporter, _ = self._run( + _, reporter, sp = self._run( ["Invite Contributors to team"], "revoke", granted={20} ) self.assertEqual( @@ -181,9 +192,11 @@ def test_revoke_single_permission_success(self): 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, _ = self._run(["Invite Contributors to team"], "revoke") + _, reporter, sp = self._run(["Invite Contributors to team"], "revoke") failure = self._message(reporter, "Could not revoke") self.assertIsNotNone(failure) self.assertIn( @@ -191,17 +204,16 @@ def test_revoke_already_revoked_logs_failure(self): "for the user.", failure, ) + self.assertEqual(sp.work_management.calls, []) # ---- cascades ------------------------------------------------------ def test_grant_master_cascades_all_contributor_permissions(self): - _, reporter, sp = self._run( - ["Manage Contributors' permissions"], "grant" - ) - # backend receives the master first, then every dependent permission + _, 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], "grant")], + [(101, [19, 20, 21, 22, 23, 24, 25])], ) success = self._message(reporter, "Successfully granted") self.assertIsNotNone(success) @@ -242,17 +254,14 @@ def test_grant_master_cascade_derived_from_live_group_data(self): ) self.assertEqual( sp.work_management.calls, - [([101], [19, 20, 21, 22, 23, 24], "grant")], + [(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" - ) - self.assertEqual( - sp.work_management.calls, [([101], [24, 23], "grant")] - ) + _, 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) @@ -263,32 +272,96 @@ def test_revoke_view_custom_fields_cascades_edit(self): "revoke", granted={23, 24}, ) - self.assertEqual( - sp.work_management.calls, [([101], [23, 24], "revoke")] - ) + # 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) + self.assertIn( + "If Manage Contributors' permissions is granted, it must be " + "revoked before", + 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] + _, sent = sp.work_management.calls[0] self.assertEqual(set(sent), set(CONTRIBUTOR_PERMS)) - self.assertEqual(sent[0], 19, "master permission must be sent first") 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] + _, sent = sp.work_management.calls[0] self.assertEqual(set(sent), set(ADMIN_PERMS)) self.assertFalse(set(sent) & set(CONTRIBUTOR_PERMS)) success = self._message(reporter, "Successfully granted") self.assertIn("View SDK Token", success) self.assertIn("Access Orchestrate", success) + # ---- 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): @@ -323,13 +396,13 @@ def test_grant_contributor_permission_for_admin_logs_role_mismatch(self): 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 not sent, so it cannot poison the - # backend's all-or-nothing batch). + # 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], "grant")]) + self.assertEqual(sp.work_management.calls, [(101, [20])]) self.assertIn( "Invite Contributors to team", self._message(reporter, "Successfully granted"), @@ -349,9 +422,10 @@ def test_invalid_permission_logs_failure_and_skips_backend(self): self.assertIn("Provided permission(s) were invalid.", failure) def test_mixed_valid_and_invalid_logs_both(self): - _, reporter, _ = self._run( + _, 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"), @@ -362,13 +436,13 @@ def test_mixed_valid_and_invalid_logs_both(self): def test_case_insensitive_permission_name(self): _, reporter, sp = self._run(["invite contributors to team"], "grant") - self.assertEqual(sp.work_management.calls, [([101], [20], "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], "grant")]) + self.assertEqual(sp.work_management.calls, [(101, [22])]) self.assertEqual( self._message(reporter, "Successfully granted"), f"Successfully granted [View Contributors’ scores] " @@ -380,7 +454,7 @@ def test_duplicate_permission_names_deduplicated(self): ["Invite Contributors to team", "invite contributors to team"], "grant", ) - self.assertEqual(sp.work_management.calls, [([101], [20], "grant")]) + self.assertEqual(sp.work_management.calls, [(101, [20])]) # ---- error paths --------------------------------------------------- @@ -407,6 +481,4 @@ def test_normalizes_curly_apostrophe_and_case(self): ) def test_left_and_right_single_quotes_normalized(self): - self.assertEqual( - UserPermissionCache._normalize_name("A‘b’c"), "a'b'c" - ) + self.assertEqual(UserPermissionCache._normalize_name("A‘b’c"), "a'b'c") From 80156c028259f418d7f08745ea44656662f8ae09 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Mon, 3 Aug 2026 18:56:39 +0400 Subject: [PATCH 07/13] added team admin permissions grant/revoke --- .../lib/app/interface/sdk_interface.py | 29 +- src/superannotate/lib/core/__init__.py | 27 +- .../lib/core/usecases/work_management.py | 131 +++++++-- .../services/work_management.py | 6 +- .../test_team_admin_user_permissions.py | 269 +++++++++++++++--- .../test_team_user_permissions.py | 35 +-- .../test_team_user_permissions_usecase.py | 247 +++++++++++++++- 7 files changed, 622 insertions(+), 122 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 768f9fe0..752aa787 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1237,13 +1237,14 @@ def grant_team_user_permissions( Possible values are - - "Manage team API keys": Only for Team Admins. Allows Team Admins to - create, rotate, and revoke team API keys. Keys may grant permissions - beyond those assigned in the UI. - - "Orchestrate": Only for Team Admins. Allows Team Admins to create and + - "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 other members API keys": Only for Team Admins. Allows Team - Admins to revoke other Team Admins' and Owner's personal API keys. + - "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 @@ -1276,7 +1277,7 @@ def grant_team_user_permissions( # To grant a specific permission by email: sa_client.grant_team_user_permissions( - permissions=["View SDK Token"], + permissions=["Access team API keys"], user="test@superannotate.com" ) @@ -1319,6 +1320,20 @@ def revoke_team_user_permissions( 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. diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 9ad214c3..c03d4852 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -156,16 +156,27 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): # 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. -TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS = { - "id": 19, - "name": "Manage Contributors’ permissions", +# +# 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 "Manage Contributors' permissions" -# master cascade (granting it grants every other permission in its group) is -# NOT hardcoded here: it is derived at runtime from the live permission-groups -# data so it only includes permissions that actually exist for the team (e.g. -# id 25 may be absent depending on the team's configuration). +# 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], } diff --git a/src/superannotate/lib/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py index b21f01c9..a7855fef 100644 --- a/src/superannotate/lib/core/usecases/work_management.py +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -13,7 +13,13 @@ PermissionOperation = Literal["grant", "revoke"] # Permission ids used by the cascade rules (see constants). -MANAGE_CONTRIBUTORS_ID = constants.TEAM_USER_PERMISSION_MANAGE_CONTRIBUTORS["id"] +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): @@ -31,10 +37,16 @@ class UpdateUserPermissionUseCase(BaseReportableUseCase): ``constants.TEAM_USER_PERMISSION_GRANT_CASCADE`` / ``TEAM_USER_PERMISSION_REVOKE_CASCADE``) because the backend does not auto-cascade; - - the "Manage Contributors' permissions" master implies every other - permission in its group: whenever it stays in the desired set we add - the rest (this also preserves the rule that members cannot be revoked - while the master is enabled); + - 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. """ @@ -75,12 +87,23 @@ def execute(self) -> Response: team_user.role, name_by_id, groups, current_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) + 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, groups) + 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): @@ -95,6 +118,7 @@ def execute(self) -> Response: unresolved_names, role_mismatch_names, team_user.email, + role_group, ) return self._response @@ -108,7 +132,7 @@ def _desired_permission_ids( self, current_ids: list[int], attempted_ids: list[int], - groups: dict[str, dict[int, str]] | None, + role_group: dict[int, str] | None, ) -> list[int]: """Full permission set to send, derived from the current set. @@ -122,18 +146,34 @@ def _desired_permission_ids( desired = current | set(attempted_ids) else: desired = current - set(attempted_ids) - desired = self._apply_master_invariant(desired, groups) + 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( - desired: set[int], groups: dict[str, dict[int, str]] | None + cls, desired: set[int], role_group: dict[int, str] | None ) -> set[int]: - if MANAGE_CONTRIBUTORS_ID not in desired or not groups: + """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 - for perms in groups.values(): - if MANAGE_CONTRIBUTORS_ID in perms: - return desired | set(perms.keys()) + 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]: @@ -162,8 +202,12 @@ def _resolve_permissions( # 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], [], [] - return list(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() @@ -184,6 +228,20 @@ def _resolve_permissions( 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], @@ -192,6 +250,7 @@ def _log( unresolved_names: list[str], role_mismatch_names: list[str], user_email: str, + role_group: dict[int, str] | None = None, ) -> None: name_by_id = self._service_provider.get_team_user_permission_id_name_map() current = set(current_ids) @@ -201,9 +260,10 @@ def _log( else: changed = current - new_state + failed_ids = [pid for pid in attempted_ids if pid not in changed] succeeded_names = [name_by_id[pid] for pid in attempted_ids if pid in changed] failed_names = ( - [name_by_id[pid] for pid in attempted_ids if pid not in changed] + [name_by_id[pid] for pid in failed_ids] + role_mismatch_names + unresolved_names ) @@ -225,11 +285,14 @@ def _log( f"- Provided permission(s) were invalid." ) 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 Manage Contributors' permissions is granted, it must be " - f"revoked before {failed_str} can be revoked for this user." + f"- If {master_name} is granted, it must be revoked before " + f"{failed_str} can be revoked for this user." ) self.reporter.log_info( f"Could not {verb_inf} {failed_str} permission(s) " @@ -255,25 +318,37 @@ 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 the "Manage Contributors' - # permissions" master cascade from the live permission-groups data: - # granting the 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). + # 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 ) - cascade = {pid: list(deps) for pid, deps in base.items()} + # 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: - master_id = MANAGE_CONTRIBUTORS_ID for perms in groups.values(): - if master_id in perms: - cascade[master_id] = [pid for pid in perms if pid != master_id] - break + 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 diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 9f331bbf..1eb571e4 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -77,7 +77,7 @@ 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_CUSTOM_ENTITIES = "customentities/edit" URL_SET_TEAM_USER_PERMISSIONS = "teamusers/setpermissions" URL_PERMISSION_GROUPS = "permissiongroups" URL_UPDATE_ANNOTATION_CLASS = "classes/{class_id}" @@ -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_USER_PERMISSIONS, + url=self.URL_EDIT_CUSTOM_ENTITIES, method="post", params=params, data={ diff --git a/tests/integration/work_management/test_team_admin_user_permissions.py b/tests/integration/work_management/test_team_admin_user_permissions.py index 90f60885..90c25762 100644 --- a/tests/integration/work_management/test_team_admin_user_permissions.py +++ b/tests/integration/work_management/test_team_admin_user_permissions.py @@ -1,5 +1,6 @@ from unittest import TestCase +from lib.core import TEAM_USER_PERMISSION_DEPRECATED_IDS from lib.core.exceptions import AppException from src.superannotate import SAClient @@ -7,52 +8,120 @@ class TestTeamAdminUserPermissions(TestCase): - # Team-admin permissions (ids 26, 27) have no apostrophes, so exact log - # assertions are stable. They are reversible via the permissions API (no - # irrevocable master like the contributor "Manage Contributors' permissions"). - PERMISSION = "View SDK Token" - OTHER_PERMISSION = "Access Orchestrate" + # "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" @classmethod def setUpClass(cls, *args, **kwargs) -> None: - cls.scapegoat = cls._find_admin(clean=True) + 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._cleanup() + cls._restore() @classmethod - def _find_admin(cls, clean: bool = False): - users = sa.list_users() - admins = [ - u - for u in users - if u.get("state") == "Confirmed" - and u.get("role") in ("TeamAdmin", "TeamOwner") - ] - if not clean: - return admins[0] - for u in admins: - full = sa.list_users(email=u["email"])[0] - if not (full.get("user_permissions") or []): - return u - return admins[0] + 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 _cleanup(cls): - # Admin permissions are reversible, so revoking each one individually - # reliably restores a clean state. - for name in (cls.OTHER_PERMISSION, cls.PERMISSION): - try: - sa.revoke_team_user_permissions( - permissions=[name], - user=cls.scapegoat["email"], + 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 _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 sa.list_users(): + if u.get("state") != "Confirmed": + continue + if u.get("role") not in ("TeamAdmin", "TeamOwner"): + continue + ids = { + p["id"] + for p in ( + sa.list_users(email=u["email"])[0].get("user_permissions") or [] ) - except Exception: - pass + } + 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 + + @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()} def tearDown(self): self._cleanup() @@ -82,29 +151,137 @@ def test_grant_permission_by_user_id(self): f"permission(s) for user: {self.scapegoat['email']}.", ) - def test_grant_all_permissions_wildcard(self): - # "*" resolves to the admin role's permissions (View SDK Token + - # Access Orchestrate). Unlike the contributor wildcard, this is fully - # reversible, so it can be exercised idempotently. + 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="*", + permissions=["Revoke members’ personal API keys"], user=self.scapegoat["email"], ) joined = "\n".join(cm.output) self.assertIn( - f"Successfully granted [{self.PERMISSION}, {self.OTHER_PERMISSION}] " - f"permission(s) for user: {self.scapegoat['email']}.", + f"Successfully granted [{self.OTHER_PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}.", joined, ) - granted = { - p["name"] - for p in ( - sa.list_users(email=self.scapegoat["email"])[0].get("user_permissions") - or [] + 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(granted, {self.PERMISSION, self.OTHER_PERMISSION}) + 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( diff --git a/tests/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py index 4e43c594..686ad610 100644 --- a/tests/integration/work_management/test_team_user_permissions.py +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -13,7 +13,7 @@ class TestTeamUserPermissions(TestCase): # 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 = "View SDK Token" + 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. @@ -123,7 +123,7 @@ def test_grant_permission_by_email(self): 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) + 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"] @@ -151,12 +151,9 @@ def test_grant_all_permissions_wildcard(self): ] self.assertTrue(success, f"expected success log, got {cm.output}") line = success[0] - for key in ( - "Manage Contributors", - "Invite Contributors to team", - "Remove Contributors from team", - "Access Workload management", - ): + # 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()) @@ -356,14 +353,9 @@ def test_grant_manage_contributors_permissions_cascade(self): ] self.assertTrue(success, f"expected success log, got {cm.output}") line = success[0] - for key in ( - "Manage Contributors", - "Invite Contributors to team", - "Remove Contributors from team", - "View Contributors", - "Edit Contributors", - "Access Workload management", - ): + # 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() @@ -420,11 +412,12 @@ def test_revoke_blocked_while_manage_enabled(self): self.assertTrue(failure, f"expected failure log, got {cm.output}") joined = "\n".join(failure) self.assertIn("Remove Contributors from team", joined) - self.assertIn( - "If Manage Contributors' permissions is granted, it must be " - "revoked before", - 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( diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py index 084cdb54..6d807f0d 100644 --- a/tests/unit/test_team_user_permissions_usecase.py +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -14,9 +14,15 @@ 24 Edit Contributors' custom field values 25 Access Workload management - Team admin permissions (ids 26-27) - 26 View SDK Token + 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 @@ -44,6 +50,15 @@ 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 = { @@ -310,11 +325,13 @@ def test_revoke_member_while_master_enabled_is_blocked(self): failure = self._message(reporter, "Could not revoke") self.assertIsNotNone(failure) self.assertIn("[Invite Contributors to team]", failure) - self.assertIn( - "If Manage Contributors' permissions is granted, it must be " - "revoked before", - 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 ------------------------------ @@ -327,11 +344,223 @@ def test_wildcard_contributor_role_grants_only_contributor_permissions(self): def test_wildcard_admin_role_grants_only_admin_permissions(self): _, reporter, sp = self._run("*", "grant", role=WMUserTypeEnum.TeamAdmin) _, sent = sp.work_management.calls[0] - self.assertEqual(set(sent), set(ADMIN_PERMS)) + # "*" 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("View SDK Token", success) 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) ---------------- From dfcaaa350ba609da853897604f4a0d2677d3c93e Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 4 Aug 2026 10:08:27 +0400 Subject: [PATCH 08/13] Implement new auth type --- docs/source/cli_client.rst | 4 + docs/source/userguide/quickstart.rst | 29 +++ pytest.ini | 2 +- .../lib/app/interface/base_interface.py | 15 +- .../lib/app/interface/cli_interface.py | 7 + .../lib/app/interface/sdk_interface.py | 8 +- src/superannotate/lib/core/entities/base.py | 15 +- .../lib/core/serviceproviders.py | 17 +- .../lib/infrastructure/controller.py | 54 ++++- .../lib/infrastructure/serviceprovider.py | 4 + .../lib/infrastructure/services/auth.py | 136 +++++++++++++ .../infrastructure/services/http_client.py | 17 +- tests/unit/test_http_client.py | 17 +- tests/unit/test_init.py | 191 ++++++++++++++++++ 14 files changed, 491 insertions(+), 25 deletions(-) create mode 100644 src/superannotate/lib/infrastructure/services/auth.py diff --git a/docs/source/cli_client.rst b/docs/source/cli_client.rst index 6fb5c7cc..712ec95d 100644 --- a/docs/source/cli_client.rst +++ b/docs/source/cli_client.rst @@ -29,6 +29,10 @@ To initialize CLI (and SDK) with team token: superannotatecli init --token [--logging_level ] [--logging_path ] + [--team_id ] + +``--team_id`` is required only for tokens that are not scoped to a team (e.g. an +organization token); it is stored as ``SA_TEAM_ID`` in the config file. ---------- diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index fa4ed7ec..ebf86101 100644 --- a/docs/source/userguide/quickstart.rst +++ b/docs/source/userguide/quickstart.rst @@ -55,6 +55,7 @@ ______________________________________________ superannotatecli init --token [--logging_level ] [--logging_path ] + [--team_id ] **Arguments provided** @@ -88,6 +89,34 @@ Custom config.ini example: LOGGING_LEVEL = INFO LOGGING_PATH = /Users/username/data/superannotate_logs + +Providing a team +________________ + +The SDK operates within a single team. Team and personal (team-user) tokens are already +scoped to a team, so nothing else is needed. An organization token is not, and the team +has to be provided explicitly: + +.. code-block:: python + + from superannotate import SAClient + + + sa_client = SAClient(token="", team_id=) + +The team can also be provided by the ``SA_TEAM_ID`` environment variable or by the config +file, in which case ``SAClient()`` picks it up on its own: + +.. code-block:: ini + + [DEFAULT] + SA_TOKEN = + SA_TEAM_ID = + +The ``team_id`` argument takes precedence over the environment variable, which takes +precedence over the config file. Passing a ``team_id`` that contradicts the team a token +is scoped to is an error. + ---------- diff --git a/pytest.ini b/pytest.ini index d9f7f6cc..c0f66b58 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/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 37ab812f..6922970b 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -28,7 +28,12 @@ class BaseInterfaceFacade: REGISTRY = [] @validate_arguments - def __init__(self, token: TokenStr | None = None, config_path: str | None = None): + def __init__( + self, + token: TokenStr | None = None, + config_path: str | None = None, + team_id: int | None = None, + ): try: if token: config = ConfigEntity(SA_TOKEN=token) @@ -65,6 +70,9 @@ def __init__(self, token: TokenStr | None = None, config_path: str | None = None raise AppException(wrap_error(e)) if not config: raise AppException("Credentials not provided.") + if team_id is not None: + # An explicitly passed team wins over the environment and the config file. + config.TEAM_ID = team_id setup_logging(config.LOGGING_LEVEL, config.LOGGING_PATH) self.controller = Controller(config) BaseInterfaceFacade.REGISTRY.append(self) @@ -80,10 +88,13 @@ def _retrieve_configs_from_json(path: Path) -> ConfigEntity: raise AppException("Invalid token.") host = json_data.get("main_endpoint") verify_ssl = json_data.get("ssl_verify") + team_id = json_data.get("team_id") if host: config.API_URL = host if verify_ssl: config.VERIFY_SSL = verify_ssl + if team_id: + config.TEAM_ID = team_id return config @staticmethod @@ -205,7 +216,7 @@ 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 properties["Success"] = success default = self.get_default_payload( diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index 80c2536b..3ac91f51 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -31,6 +31,7 @@ def init( token: str, logging_level: str = "INFO", logging_path: str = constances.LOG_FILE_LOCATION, + team_id: int = None, ): """ To initialize CLI (and SDK) with team token @@ -45,6 +46,10 @@ def init( :param logging_path: logging path for log file :type logging_path: str + :param team_id: the team to operate in, required only for tokens that are not + scoped to a team (e.g. an organization token) + :type team_id: int + """ from configparser import ConfigParser @@ -64,6 +69,8 @@ def init( "LOGGING_LEVEL": logging_level, "LOGGING_PATH": logging_path, } + if team_id: + config_parser["DEFAULT"]["SA_TEAM_ID"] = str(team_id) with open(constances.CONFIG_INI_FILE_LOCATION, "w") as configfile: config_parser.write(configfile) print(f"Configuration file successfully {operation}.") diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 752aa787..b319c396 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -301,14 +301,20 @@ class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta): :param config_path: path to config file :type config_path: path-like (str or Path) + :param team_id: the team to operate in. Required only for tokens that are not scoped + to a team (e.g. an organization token); can also be provided via the SA_TEAM_ID + environment variable or the config file. + :type team_id: int + """ def __init__( self, token: str | None = None, config_path: str | None = None, + team_id: int | None = None, ): - super().__init__(token, config_path) + super().__init__(token, config_path, team_id) def get_project_by_id(self, project_id: int): """Returns the project metadata diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 008f6ea6..6e4815cb 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 @@ -120,6 +132,7 @@ class ConfigEntity(BaseModel): API_TOKEN: TokenStr = Field(alias="SA_TOKEN") API_URL: str = Field(alias="SA_URL", default=BACKEND_URL) + TEAM_ID: int | None = Field(alias="SA_TEAM_ID", default=None) LOGGING_LEVEL: Literal[ "NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" ] = "INFO" diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 80712b64..7212d8b2 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -43,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): @@ -57,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): diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index c2edc57b..18a0805b 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -60,6 +60,7 @@ 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.http_client import HttpClient from lib.infrastructure.utils import divide_to_chunks from lib.infrastructure.utils import extract_project_folder @@ -633,9 +634,15 @@ def _resolve_team_user(self, user: int | str): 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( @@ -1671,15 +1678,29 @@ 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, + team_id=config.TEAM_ID, + ) + 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) @@ -1694,14 +1715,16 @@ 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 team(self): + def team(self) -> TeamEntity: + if self._team is None: + self._team = self.get_team().data return self._team def get_team(self): @@ -1710,6 +1733,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() @@ -1723,11 +1750,20 @@ def team_data(self): self._team_data = self.team return self._team_data + @property + def team_name(self) -> str: + """The team name once known, the team id otherwise. + + Deliberately never triggers a team lookup — it exists for telemetry, which must + not make the client fetch data it does not otherwise need. + """ + return self._team.name if self._team else str(self.team_id) + @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( diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 2545616f..4c4ddf9f 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -60,6 +60,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 +69,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, ) ) diff --git a/src/superannotate/lib/infrastructure/services/auth.py b/src/superannotate/lib/infrastructure/services/auth.py new file mode 100644 index 00000000..821226ee --- /dev/null +++ b/src/superannotate/lib/infrastructure/services/auth.py @@ -0,0 +1,136 @@ +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" + +#: Token scopes that carry a team, and therefore need no explicit team_id. +TEAM_SCOPED_TYPES = ("team", "teamuser") + +TEAM_CONTEXT_REQUIRED_ERROR = ( + "The provided token is not scoped to a team, and the SDK operates within a team. " + "Provide a team by passing team_id to SAClient(...), by setting the SA_TEAM_ID " + "environment variable, or by adding SA_TEAM_ID to the config file." +) +TEAM_ID_MISMATCH_ERROR = ( + "The provided team_id ({provided}) does not match the team the token is scoped " + "to ({actual}). Omit team_id to use the token's own team." +) +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 + + @property + def is_legacy(self) -> bool: + return self.auth_type == SDK_AUTH_TYPE + + +def resolve_token_context( + api_url: str, + token: str, + verify_ssl: bool = True, + team_id: int | None = None, +) -> 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. + """ + if is_legacy_token(token): + token_team_id = int(token.split("=")[-1]) + if team_id is not None and team_id != token_team_id: + raise AppException( + TEAM_ID_MISMATCH_ERROR.format(provided=team_id, actual=token_team_id) + ) + return TokenContext(team_id=token_team_id, 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") + + if token_team_id is None: + # Organization-scoped (or any other team-less) key: the caller has to say which + # team to work in. + if team_id is None: + raise AppException(TEAM_CONTEXT_REQUIRED_ERROR) + resolved_team_id = team_id + else: + if team_id is not None and int(team_id) != int(token_team_id): + raise AppException( + TEAM_ID_MISMATCH_ERROR.format(provided=team_id, actual=token_team_id) + ) + resolved_team_id = int(token_team_id) + + logger.debug(f"Token resolved to {scope_type} scope, team {resolved_team_id}.") + return TokenContext( + team_id=resolved_team_id, + auth_type=API_KEY_AUTH_TYPE, + user=_build_user(data.get("user"), token_data.get("created_by")), + ) + + +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 75be8357..56ef7fe7 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( @@ -126,7 +131,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 {request.url} response from backend:, {response.text}" ) return response diff --git a/tests/unit/test_http_client.py b/tests/unit/test_http_client.py index 209cb01c..d6edb333 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 7b1a8aec..c00f6a3e 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -4,6 +4,7 @@ from configparser import ConfigParser 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 +165,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 token already carries the team, so no team lookup on init. + assert get_team.call_count == 0 + + 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 + + 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_lazily(self, post, get_team): + 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 == 0 + assert sa.controller.org_id == "org-1" + assert get_team.call_count == 1 + # Cached afterwards. + 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" + + 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_init_via_organization_token_without_team(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with self.assertRaisesRegex(AppException, r"not scoped to a team"): + SAClient(token=self._token) + + def test_init_via_organization_token_with_team_id(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + sa = SAClient(token=self._token, team_id=6085) + + assert sa.controller.team_id == 6085 + assert sa.controller.service_provider.client.team_id == 6085 + assert sa.controller.current_user.email == "vaghinak@superannotate.com" + + def test_init_via_organization_token_with_team_id_from_env(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with patch.dict(os.environ, {"SA_TOKEN": self._token, "SA_TEAM_ID": "6085"}): + sa = SAClient() + + assert sa.controller.team_id == 6085 + + def test_init_via_organization_token_with_team_id_from_ini(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with tempfile.TemporaryDirectory() as config_dir: + config_path = f"{config_dir}/config.ini" + with open(config_path, "w") as config_ini: + config_parser = ConfigParser() + config_parser.optionxform = str + config_parser["DEFAULT"] = { + "SA_TOKEN": self._token, + "SA_TEAM_ID": "6085", + } + config_parser.write(config_ini) + sa = SAClient(config_path=config_path) + + assert sa.controller.team_id == 6085 + + def test_explicit_team_id_wins_over_config(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with patch.dict(os.environ, {"SA_TOKEN": self._token, "SA_TEAM_ID": "1"}): + sa = SAClient(team_id=6085) + + assert sa.controller.team_id == 6085 + + def test_team_id_mismatch_raises(self, post, get_team): + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + with self.assertRaisesRegex(AppException, r"does not match the team"): + SAClient(token=self._token, team_id=42) + + 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 LegacyTokenTeamIdTestCase(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" + + @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) From ce44868434f49df524d91f62a15a698738e81229 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 6 Aug 2026 15:17:30 +0400 Subject: [PATCH 09/13] fix in team permissions logging --- .../lib/core/usecases/work_management.py | 31 ++++++- .../test_team_admin_user_permissions.py | 25 ++++++ .../test_team_user_permissions.py | 44 +++++++++- .../test_team_user_permissions_usecase.py | 80 +++++++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/superannotate/lib/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py index a7855fef..2c7729a0 100644 --- a/src/superannotate/lib/core/usecases/work_management.py +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -48,6 +48,11 @@ class UpdateUserPermissionUseCase(BaseReportableUseCase): (``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__( @@ -87,6 +92,10 @@ def execute(self) -> Response: 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. @@ -119,6 +128,7 @@ def execute(self) -> Response: role_mismatch_names, team_user.email, role_group, + explicit_ids, ) return self._response @@ -251,6 +261,7 @@ def _log( 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) @@ -260,7 +271,25 @@ def _log( else: changed = current - new_state - failed_ids = [pid for pid in attempted_ids if pid not in changed] + 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] diff --git a/tests/integration/work_management/test_team_admin_user_permissions.py b/tests/integration/work_management/test_team_admin_user_permissions.py index 90c25762..96ceeaa5 100644 --- a/tests/integration/work_management/test_team_admin_user_permissions.py +++ b/tests/integration/work_management/test_team_admin_user_permissions.py @@ -451,3 +451,28 @@ def test_revoke_unknown_user_raises(self): permissions=[self.PERMISSION], user="non_existent_admin@superannotate.com", ) + + 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, so nothing may be reported as a failure. + email = self.scapegoat["email"] + sa.grant_team_user_permissions( + permissions=[self.PERMISSION, self.OTHER_PERMISSION], + user=email, + ) + self.assertEqual(self._granted(), {self.PERMISSION, self.OTHER_PERMISSION}) + + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_team_user_permissions( + permissions=[self.MASTER_PERMISSION], + user=email, + ) + joined = "\n".join(cm.output) + self.assertIn( + f"INFO:sa:Successfully granted [{self.MASTER_PERMISSION}] " + f"permission(s) for user: {email}.", + joined, + ) + self.assertNotIn("Could not grant", joined) + # The master still pulled the whole group in. + self.assertEqual(self._granted(), self._admin_permission_names()) diff --git a/tests/integration/work_management/test_team_user_permissions.py b/tests/integration/work_management/test_team_user_permissions.py index 686ad610..1ede2cb5 100644 --- a/tests/integration/work_management/test_team_user_permissions.py +++ b/tests/integration/work_management/test_team_user_permissions.py @@ -361,6 +361,40 @@ def test_grant_manage_contributors_permissions_cascade(self): 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. @@ -547,9 +581,13 @@ def test_grant_edit_when_view_already_granted_leaves_both_set(self): ), "setup failed: View was not granted", ) - sa.grant_team_user_permissions( - permissions=[self.EDIT_CUSTOM_FIELDS], user=email - ) + 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"), diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py index 6d807f0d..72b99f48 100644 --- a/tests/unit/test_team_user_permissions_usecase.py +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -198,6 +198,86 @@ def test_grant_already_granted_logs_failure(self): # 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} From 20bc9223226e3278121a028cc010bafe3f546acd Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 11 Aug 2026 16:36:23 +0400 Subject: [PATCH 10/13] Reject Organization API keys in SAClient and drop team_id, update docs --- README.rst | 35 +-------- docs/source/cli_client.rst | 8 +- docs/source/userguide/quickstart.rst | 47 ++++-------- .../lib/app/interface/base_interface.py | 13 +--- .../lib/app/interface/cli_interface.py | 13 +--- .../lib/app/interface/sdk_interface.py | 16 +--- src/superannotate/lib/core/entities/base.py | 1 - .../lib/infrastructure/controller.py | 1 - .../lib/infrastructure/services/auth.py | 47 ++++-------- .../infrastructure/services/http_client.py | 2 +- tests/unit/test_init.py | 73 ++++++------------- 11 files changed, 65 insertions(+), 191 deletions(-) diff --git a/README.rst b/README.rst index f191ff4a..5df84946 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/cli_client.rst b/docs/source/cli_client.rst index 712ec95d..976fab70 100644 --- a/docs/source/cli_client.rst +++ b/docs/source/cli_client.rst @@ -22,17 +22,13 @@ ________________________ 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 ] - [--team_id ] - -``--team_id`` is required only for tokens that are not scoped to a team (e.g. an -organization token); it is stored as ``SA_TEAM_ID`` in the config file. ---------- diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index ebf86101..5a72270e 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,10 +60,9 @@ ______________________________________________ .. code-block:: bash - superannotatecli init --token + superannotatecli init --token [--logging_level ] [--logging_path ] - [--team_id ] **Arguments provided** @@ -67,7 +74,7 @@ ______________________________________________ from superannotate import SAClient - SAClient(token="") + sa_client = SAClient(token="") *Method 2:* Create a custom config file: @@ -85,38 +92,10 @@ Custom config.ini example: .. code-block:: ini [DEFAULT] - SA_TOKEN = + SA_TOKEN = LOGGING_LEVEL = INFO LOGGING_PATH = /Users/username/data/superannotate_logs - -Providing a team -________________ - -The SDK operates within a single team. Team and personal (team-user) tokens are already -scoped to a team, so nothing else is needed. An organization token is not, and the team -has to be provided explicitly: - -.. code-block:: python - - from superannotate import SAClient - - - sa_client = SAClient(token="", team_id=) - -The team can also be provided by the ``SA_TEAM_ID`` environment variable or by the config -file, in which case ``SAClient()`` picks it up on its own: - -.. code-block:: ini - - [DEFAULT] - SA_TOKEN = - SA_TEAM_ID = - -The ``team_id`` argument takes precedence over the environment variable, which takes -precedence over the config file. Passing a ``team_id`` that contradicts the team a token -is scoped to is an error. - ---------- diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 6922970b..78c56526 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -28,12 +28,7 @@ class BaseInterfaceFacade: REGISTRY = [] @validate_arguments - def __init__( - self, - token: TokenStr | None = None, - config_path: str | None = None, - team_id: int | None = None, - ): + def __init__(self, token: TokenStr | None = None, config_path: str | None = None): try: if token: config = ConfigEntity(SA_TOKEN=token) @@ -70,9 +65,6 @@ def __init__( raise AppException(wrap_error(e)) if not config: raise AppException("Credentials not provided.") - if team_id is not None: - # An explicitly passed team wins over the environment and the config file. - config.TEAM_ID = team_id setup_logging(config.LOGGING_LEVEL, config.LOGGING_PATH) self.controller = Controller(config) BaseInterfaceFacade.REGISTRY.append(self) @@ -88,13 +80,10 @@ def _retrieve_configs_from_json(path: Path) -> ConfigEntity: raise AppException("Invalid token.") host = json_data.get("main_endpoint") verify_ssl = json_data.get("ssl_verify") - team_id = json_data.get("team_id") if host: config.API_URL = host if verify_ssl: config.VERIFY_SSL = verify_ssl - if team_id: - config.TEAM_ID = team_id return config @staticmethod diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index 3ac91f51..4940581c 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -31,13 +31,12 @@ def init( token: str, logging_level: str = "INFO", logging_path: str = constances.LOG_FILE_LOCATION, - team_id: int = None, ): """ - 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" @@ -46,10 +45,6 @@ def init( :param logging_path: logging path for log file :type logging_path: str - :param team_id: the team to operate in, required only for tokens that are not - scoped to a team (e.g. an organization token) - :type team_id: int - """ from configparser import ConfigParser @@ -69,8 +64,6 @@ def init( "LOGGING_LEVEL": logging_level, "LOGGING_PATH": logging_path, } - if team_id: - config_parser["DEFAULT"]["SA_TEAM_ID"] = str(team_id) with open(constances.CONFIG_INI_FILE_LOCATION, "w") as configfile: config_parser.write(configfile) print(f"Configuration file successfully {operation}.") diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index b319c396..cd5f7ddd 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -295,26 +295,16 @@ 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 :type config_path: path-like (str or Path) - :param team_id: the team to operate in. Required only for tokens that are not scoped - to a team (e.g. an organization token); can also be provided via the SA_TEAM_ID - environment variable or the config file. - :type team_id: int - """ - def __init__( - self, - token: str | None = None, - config_path: str | None = None, - team_id: int | None = None, - ): - super().__init__(token, config_path, team_id) + 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): """Returns the project metadata diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 6e4815cb..2208b60d 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -132,7 +132,6 @@ class ConfigEntity(BaseModel): API_TOKEN: TokenStr = Field(alias="SA_TOKEN") API_URL: str = Field(alias="SA_URL", default=BACKEND_URL) - TEAM_ID: int | None = Field(alias="SA_TEAM_ID", default=None) LOGGING_LEVEL: Literal[ "NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" ] = "INFO" diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 18a0805b..57f0c551 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1682,7 +1682,6 @@ def __init__(self, config: ConfigEntity): api_url=config.API_URL, token=config.API_TOKEN, verify_ssl=config.VERIFY_SSL, - team_id=config.TEAM_ID, ) self._team_id = self._token_context.team_id diff --git a/src/superannotate/lib/infrastructure/services/auth.py b/src/superannotate/lib/infrastructure/services/auth.py index 821226ee..50e8c2af 100644 --- a/src/superannotate/lib/infrastructure/services/auth.py +++ b/src/superannotate/lib/infrastructure/services/auth.py @@ -16,17 +16,12 @@ URL_TOKEN_CONTEXT = "users/me" -#: Token scopes that carry a team, and therefore need no explicit team_id. +#: Token scopes that carry a team: "team" is a Team key, "teamuser" a Personal key. TEAM_SCOPED_TYPES = ("team", "teamuser") -TEAM_CONTEXT_REQUIRED_ERROR = ( - "The provided token is not scoped to a team, and the SDK operates within a team. " - "Provide a team by passing team_id to SAClient(...), by setting the SA_TEAM_ID " - "environment variable, or by adding SA_TEAM_ID to the config file." -) -TEAM_ID_MISMATCH_ERROR = ( - "The provided team_id ({provided}) does not match the team the token is scoped " - "to ({actual}). Omit team_id to use the token's own team." +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." @@ -50,21 +45,16 @@ def resolve_token_context( api_url: str, token: str, verify_ssl: bool = True, - team_id: int | None = None, ) -> 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 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): - token_team_id = int(token.split("=")[-1]) - if team_id is not None and team_id != token_team_id: - raise AppException( - TEAM_ID_MISMATCH_ERROR.format(provided=team_id, actual=token_team_id) - ) - return TokenContext(team_id=token_team_id, auth_type=SDK_AUTH_TYPE) + 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 {} @@ -72,22 +62,15 @@ def resolve_token_context( scope_type = token_data.get("scope_type") token_team_id = scope.get("team_id") - if token_team_id is None: - # Organization-scoped (or any other team-less) key: the caller has to say which - # team to work in. - if team_id is None: - raise AppException(TEAM_CONTEXT_REQUIRED_ERROR) - resolved_team_id = team_id - else: - if team_id is not None and int(team_id) != int(token_team_id): - raise AppException( - TEAM_ID_MISMATCH_ERROR.format(provided=team_id, actual=token_team_id) - ) - resolved_team_id = int(token_team_id) - - logger.debug(f"Token resolved to {scope_type} scope, team {resolved_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=resolved_team_id, + team_id=int(token_team_id), auth_type=API_KEY_AUTH_TYPE, user=_build_user(data.get("user"), token_data.get("created_by")), ) diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index 56ef7fe7..1ba3f313 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -131,7 +131,7 @@ def _request(self, url, method, session, retried=0, **kwargs): ) if response.status_code > 299: logger.debug( - f"Got {response.status_code} from {request.url} response from backend:, {response.text}" + f"Got {response.status_code} from {url} response from backend:, {response.text}" ) return response diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index c00f6a3e..8e773eeb 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -2,6 +2,7 @@ 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 @@ -285,53 +286,31 @@ def test_nested_service_clients_share_team_context(self, post, get_team): assert service.client.team_id == 6085 assert service.client.auth_type == "api_key" - def test_init_via_organization_token_without_team(self, post, get_team): + def test_organization_api_key_rejected(self, post, get_team): post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) - with self.assertRaisesRegex(AppException, r"not scoped to a team"): + with self.assertRaisesRegex( + AppException, r"does not accept an Organization API key" + ): SAClient(token=self._token) - def test_init_via_organization_token_with_team_id(self, post, get_team): - post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) - sa = SAClient(token=self._token, team_id=6085) - - assert sa.controller.team_id == 6085 - assert sa.controller.service_provider.client.team_id == 6085 - assert sa.controller.current_user.email == "vaghinak@superannotate.com" - - def test_init_via_organization_token_with_team_id_from_env(self, post, get_team): - post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) - with patch.dict(os.environ, {"SA_TOKEN": self._token, "SA_TEAM_ID": "6085"}): - sa = SAClient() - - assert sa.controller.team_id == 6085 - - def test_init_via_organization_token_with_team_id_from_ini(self, post, get_team): - post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) - with tempfile.TemporaryDirectory() as config_dir: - config_path = f"{config_dir}/config.ini" - with open(config_path, "w") as config_ini: - config_parser = ConfigParser() - config_parser.optionxform = str - config_parser["DEFAULT"] = { - "SA_TOKEN": self._token, - "SA_TEAM_ID": "6085", - } - config_parser.write(config_ini) - sa = SAClient(config_path=config_path) - - assert sa.controller.team_id == 6085 - - def test_explicit_team_id_wins_over_config(self, post, get_team): - post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) - with patch.dict(os.environ, {"SA_TOKEN": self._token, "SA_TEAM_ID": "1"}): - sa = SAClient(team_id=6085) - - assert sa.controller.team_id == 6085 + 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_id_mismatch_raises(self, post, get_team): - post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) - with self.assertRaisesRegex(AppException, r"does not match the team"): - SAClient(token=self._token, team_id=42) + 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) @@ -339,7 +318,7 @@ def test_authentication_failure(self, post, get_team): SAClient(token=self._token) -class LegacyTokenTeamIdTestCase(TestCase): +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") @@ -349,9 +328,3 @@ def test_legacy_token_resolves_offline(self, post, get_team, get_current_user): assert post.call_count == 0 assert sa.controller.team_id == 123 assert sa.controller.service_provider.client.auth_type == "sdk" - - @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) From 256b463067f77e5580d178681edb50a9669205d2 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 12 Aug 2026 15:04:33 +0400 Subject: [PATCH 11/13] removed deprecated functions --- docs/source/api_reference/api_item.rst | 1 - docs/source/api_reference/api_team.rst | 1 - docs/source/userguide/SDK_Functions_sheet.csv | 2 - src/superannotate/__init__.py | 2 +- .../lib/app/interface/sdk_interface.py | 166 +----------------- .../lib/core/serviceproviders.py | 6 - .../lib/core/usecases/projects.py | 18 -- .../lib/infrastructure/controller.py | 9 - .../lib/infrastructure/serviceprovider.py | 9 +- ...om_workflow.py => test_custom_workflow.py} | 8 +- .../test_annotation_upload_vector.py | 2 +- .../annotations/test_get_annotations.py | 8 +- .../annotations/test_preannotation_upload.py | 2 +- .../annotations/test_upload_annotations.py | 4 +- ...load_annotations_from_folder_to_project.py | 2 +- .../custom_fields/test_custom_schema.py | 17 +- tests/integration/items/test_copy_items.py | 14 +- tests/integration/items/test_item_context.py | 2 +- tests/integration/items/test_move_items.py | 22 +-- tests/integration/items/test_search_items.py | 79 --------- .../items/test_set_annotation_statuses.py | 4 +- .../items/test_set_approval_statuses.py | 6 +- .../mixpanel/test_mixpanel_decorator.py | 15 -- tests/integration/test_cli.py | 6 +- tests/integration/test_image_upload.py | 4 +- tests/integration/test_recursive_folder.py | 16 +- tests/integration/test_video.py | 12 +- 27 files changed, 66 insertions(+), 371 deletions(-) rename tests/applicatoin/{custom_workflow.py => test_custom_workflow.py} (97%) delete mode 100644 tests/integration/items/test_search_items.py diff --git a/docs/source/api_reference/api_item.rst b/docs/source/api_reference/api_item.rst index e25f5789..b7aa1851 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 abb70185..d34c1b0f 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 diff --git a/docs/source/userguide/SDK_Functions_sheet.csv b/docs/source/userguide/SDK_Functions_sheet.csv index 434f0fdc..8c929f56 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/src/superannotate/__init__.py b/src/superannotate/__init__.py index 7a3fde92..f090dd86 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.10dev1" +__version__ = "4.6.0dev1" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index b319c396..465e7b60 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1440,44 +1440,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, @@ -1506,7 +1468,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, @@ -2112,7 +2074,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()" ) ) @@ -4788,128 +4750,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, @@ -5873,7 +5713,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/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 7212d8b2..64fb3057 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -935,12 +935,6 @@ def get_project_images_count( ) -> ServiceResponse: raise NotImplementedError - @abstractmethod - def search_team_contributors( - self, condition: Condition | None = None - ) -> ServiceResponse: - raise NotImplementedError - @abstractmethod def get_team_user_permission_id(self, name: str) -> int | None: raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 0a165d87..302c5de7 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/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 18a0805b..fc0a35a4 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1951,15 +1951,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, diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 4c4ddf9f..5cfb60f2 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 @@ -355,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/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 f8856254..fcca462c 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.liest_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 727d3a23..6d166be5 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 3a2de83e..1b12cccb 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(f"{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 85122330..d450e6af 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 59b842af..7eb4f953 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -68,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" @@ -92,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" 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 4c13a609..cc3e8526 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 fb06765d..f6e47f84 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,7 +108,7 @@ 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( + data = sa.list_items( self.PROJECT_NAME, name_contains=item_name, include_custom_metadata=True ) assert data[0]["custom_metadata"] == payload @@ -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 eaff6381..499d895c 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(f"{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(f"{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(f"{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(f"{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(f"{self.PROJECT_NAME}/{self.FOLDER_1}") + folder_2_items = sa.list_items(f"{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(f"{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_item_context.py b/tests/integration/items/test_item_context.py index bf30b138..425b6e33 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -105,7 +105,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(f"{self.PROJECT_NAME}/folder", "dummy")[0] self._base_test((self._project["id"], folder["id"]), item["id"]) def test_set_component_value_stamps_last_action(self): diff --git a/tests/integration/items/test_move_items.py b/tests/integration/items/test_move_items.py index 00232eb4..fcc6eb25 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(f"{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(f"{self.PROJECT_NAME}/{self.FOLDER_2}")) == 2 + assert len(sa.list_items(f"{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(f"{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(f"{self.PROJECT_NAME}/{self.FOLDER_1}") + folder_2_items = sa.list_items(f"{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(f"{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(f"{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(f"{self.PROJECT_NAME}/{self.FOLDER_1}") + folder_2_items = sa.list_items(f"{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(f"{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 ecd4720a..00000000 --- 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 01dddb12..fdafcbe6 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 15324146..b448b2a8 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 855bf3c4..97de0431 100644 --- a/tests/integration/mixpanel/test_mixpanel_decorator.py +++ b/tests/integration/mixpanel/test_mixpanel_decorator.py @@ -118,21 +118,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 55dc8ab7..4d862767 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 86f93453..23474021 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 657e278d..adcc5537 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 6ff1e0bf..63d580f8 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): @@ -105,9 +101,7 @@ def test_video_big(self): ) self.assertEqual( len( - sa.search_items( - f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME_BIG_VIDEO}" - ) + sa.list_items(f"{self.PROJECT_NAME}/{self.TEST_FOLDER_NAME_BIG_VIDEO}") ), 31, ) From e295224aef659c4b3eabbf4842a8223e491e4a75 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 12 Aug 2026 15:44:59 +0400 Subject: [PATCH 12/13] updated changelog --- CHANGELOG.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 34747cf4..a1904b88 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** From a156f42e21abeef332456841468b628e51107927 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 13 Aug 2026 15:11:59 +0400 Subject: [PATCH 13/13] Fix tests --- .../lib/app/interface/base_interface.py | 8 +- .../lib/core/usecases/work_management.py | 6 +- .../lib/infrastructure/controller.py | 21 +- .../lib/infrastructure/services/auth.py | 22 +- .../infrastructure/services/http_client.py | 3 +- tests/applicatoin/test_custom_workflow.py | 2 +- .../annotations/test_get_annotations.py | 2 +- .../custom_fields/test_custom_schema.py | 2 +- tests/integration/items/test_copy_items.py | 14 +- .../integration/items/test_generate_items.py | 2 +- tests/integration/items/test_item_context.py | 8 +- tests/integration/items/test_move_items.py | 22 +- .../mixpanel/test_mixpanel_decorator.py | 10 +- tests/integration/test_video.py | 4 +- .../test_team_admin_user_permissions.py | 275 +++++++++++++++--- tests/unit/test_init.py | 34 ++- .../test_team_user_permissions_usecase.py | 78 ++++- 17 files changed, 414 insertions(+), 99 deletions(-) diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 6922970b..ab4bc35d 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -135,11 +135,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(), @@ -217,10 +220,11 @@ def _track_method(self, args, kwargs, success: bool): event_name, properties = self.default_parser(function_name, arguments) user_email = client.controller.current_user.email 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/core/usecases/work_management.py b/src/superannotate/lib/core/usecases/work_management.py index a7855fef..d06adfc7 100644 --- a/src/superannotate/lib/core/usecases/work_management.py +++ b/src/superannotate/lib/core/usecases/work_management.py @@ -282,7 +282,8 @@ def _log( reasons = ( f"- User already has {failed_str} permission(s) granted.\n" f"- User role does not allow {failed_str} permission(s).\n" - f"- Provided permission(s) were invalid." + "- 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 @@ -292,7 +293,8 @@ def _log( 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." + 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) " diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index fc0a35a4..af4391cd 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -61,6 +61,7 @@ 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 @@ -1667,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 @@ -1721,6 +1721,11 @@ def org_id(self): def current_user(self): return self._user + @property + def token_context(self) -> TokenContext: + """The scope the client authenticated with (team / team-user / legacy).""" + return self._token_context + @property def team(self) -> TeamEntity: if self._team is None: @@ -1744,20 +1749,14 @@ def get_current_user(self) -> UserEntity: raise AppException(response.errors) return response.data - @property - def team_data(self): - if not self._team_data: - self._team_data = self.team - return self._team_data - @property def team_name(self) -> str: - """The team name once known, the team id otherwise. + """The name of the team the client operates in. - Deliberately never triggers a team lookup — it exists for telemetry, which must - not make the client fetch data it does not otherwise need. + 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 if self._team else str(self.team_id) + return self.team.name @property def team_id(self) -> int: diff --git a/src/superannotate/lib/infrastructure/services/auth.py b/src/superannotate/lib/infrastructure/services/auth.py index 821226ee..941cf758 100644 --- a/src/superannotate/lib/infrastructure/services/auth.py +++ b/src/superannotate/lib/infrastructure/services/auth.py @@ -16,8 +16,14 @@ 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", "teamuser") +TEAM_SCOPED_TYPES = (TEAM_SCOPE_TYPE, TEAM_USER_SCOPE_TYPE) TEAM_CONTEXT_REQUIRED_ERROR = ( "The provided token is not scoped to a team, and the SDK operates within a team. " @@ -40,11 +46,24 @@ class TokenContext: 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, @@ -90,6 +109,7 @@ def resolve_token_context( team_id=resolved_team_id, auth_type=API_KEY_AUTH_TYPE, user=_build_user(data.get("user"), token_data.get("created_by")), + scope_type=scope_type, ) diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index 56ef7fe7..a74e7c8d 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -123,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( @@ -131,7 +130,7 @@ def _request(self, url, method, session, retried=0, **kwargs): ) if response.status_code > 299: logger.debug( - f"Got {response.status_code} from {request.url} response from backend:, {response.text}" + f"Got {response.status_code} from {method} {url} response from backend:, {response.text}" ) return response diff --git a/tests/applicatoin/test_custom_workflow.py b/tests/applicatoin/test_custom_workflow.py index fcca462c..bbc8db16 100644 --- a/tests/applicatoin/test_custom_workflow.py +++ b/tests/applicatoin/test_custom_workflow.py @@ -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.liest_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_get_annotations.py b/tests/integration/annotations/test_get_annotations.py index 1b12cccb..a4c9c409 100644 --- a/tests/integration/annotations/test_get_annotations.py +++ b/tests/integration/annotations/test_get_annotations.py @@ -73,7 +73,7 @@ def test_get_annotations_by_ids_with_duplicate_names(self): f"{self.PROJECT_NAME}/{self.FOLDER_NAME_2}", self.folder_path ) items = sa.list_items(self.PROJECT_NAME) - folder_items = sa.list_items(f"{self.PROJECT_NAME}/{self.FOLDER_NAME_2}") + folder_items = sa.list_items(self.PROJECT_NAME, self.FOLDER_NAME_2) all_items = items + folder_items annotations = sa.get_annotations( diff --git a/tests/integration/custom_fields/test_custom_schema.py b/tests/integration/custom_fields/test_custom_schema.py index f6e47f84..6950ac96 100644 --- a/tests/integration/custom_fields/test_custom_schema.py +++ b/tests/integration/custom_fields/test_custom_schema.py @@ -109,7 +109,7 @@ def test_upload_delete_custom_values_list_items(self): ) assert response == {"failed": [], "succeeded": [item_name]} data = sa.list_items( - self.PROJECT_NAME, name_contains=item_name, include_custom_metadata=True + self.PROJECT_NAME, name__contains=item_name, include=["custom_metadata"] ) assert data[0]["custom_metadata"] == payload data = sa.list_items( diff --git a/tests/integration/items/test_copy_items.py b/tests/integration/items/test_copy_items.py index 499d895c..c67a2c11 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.list_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.list_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.list_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.list_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.list_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") - folder_2_items = sa.list_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.list_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 03a800b0..ab3a1b60 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 425b6e33..864054c6 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.list_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 fcc6eb25..111f6373 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.list_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.list_items(f"{self.PROJECT_NAME}/{self.FOLDER_2}")) == 2 - assert len(sa.list_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.list_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.list_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") - folder_2_items = sa.list_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.list_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.list_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.list_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}") - folder_2_items = sa.list_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.list_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/mixpanel/test_mixpanel_decorator.py b/tests/integration/mixpanel/test_mixpanel_decorator.py index 97de0431..3212035c 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, } diff --git a/tests/integration/test_video.py b/tests/integration/test_video.py index 63d580f8..7328f21f 100644 --- a/tests/integration/test_video.py +++ b/tests/integration/test_video.py @@ -100,9 +100,7 @@ def test_video_big(self): target_fps=1, ) self.assertEqual( - len( - sa.list_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 index 90c25762..6ee69a7e 100644 --- a/tests/integration/work_management/test_team_admin_user_permissions.py +++ b/tests/integration/work_management/test_team_admin_user_permissions.py @@ -1,3 +1,6 @@ +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 @@ -6,8 +9,26 @@ 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.""" -class TestTeamAdminUserPermissions(TestCase): # "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. @@ -20,6 +41,52 @@ class TestTeamAdminUserPermissions(TestCase): # 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() @@ -44,22 +111,6 @@ def _restore(cls): permission_ids=list(cls.original_permission_ids), ) - @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 _find_admin(cls): """Pick an admin whose permission state the suite can safely restore. @@ -72,11 +123,7 @@ def _find_admin(cls): Prefer an admin with no permissions, then any whose set is restorable. """ candidates = [] - for u in sa.list_users(): - if u.get("state") != "Confirmed": - continue - if u.get("role") not in ("TeamAdmin", "TeamOwner"): - continue + for u in cls._confirmed_admins(): ids = { p["id"] for p in ( @@ -109,20 +156,6 @@ def _cleanup(cls): except Exception: pass - @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()} - def tearDown(self): self._cleanup() @@ -303,6 +336,9 @@ def test_grant_already_granted_logs_failure(self): 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( @@ -361,6 +397,7 @@ def test_revoke_already_revoked_logs_failure(self): 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: @@ -451,3 +488,169 @@ def test_revoke_unknown_user_raises(self): 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/unit/test_init.py b/tests/unit/test_init.py index c00f6a3e..d97e753b 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -233,8 +233,9 @@ def test_init_via_team_token(self, post, get_team): 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 token already carries the team, so no team lookup on init. - assert get_team.call_count == 0 + # 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 @@ -242,6 +243,14 @@ def test_init_via_team_token(self, post, get_team): 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) @@ -254,15 +263,16 @@ def test_token_context_request(self, post, get_team): assert headers["authtype"] == "api_key" assert headers["Authorization"] == self._token - def test_team_is_fetched_lazily(self, post, get_team): + 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 == 0 - assert sa.controller.org_id == "org-1" assert get_team.call_count == 1 - # Cached afterwards. + assert sa.controller.org_id == "org-1" assert sa.controller.team.owner_id == "org-1" assert get_team.call_count == 1 @@ -274,6 +284,12 @@ def test_init_via_team_user_token(self, post, get_team): 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) @@ -349,6 +365,12 @@ def test_legacy_token_resolves_offline(self, post, get_team, get_current_user): 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") diff --git a/tests/unit/test_team_user_permissions_usecase.py b/tests/unit/test_team_user_permissions_usecase.py index 6d807f0d..a0bc5e00 100644 --- a/tests/unit/test_team_user_permissions_usecase.py +++ b/tests/unit/test_team_user_permissions_usecase.py @@ -90,21 +90,28 @@ def __init__( 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).""" + set is echoed back (as the real endpoint does). - def __init__(self, granted): + 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))) - self.granted = set(permission_ids) - return 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): - self.work_management = _FakeWorkManagementService(granted) + 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 @@ -135,6 +142,7 @@ def _run( 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 @@ -142,7 +150,7 @@ def _run( 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 + granted=current, groups=groups, name_by_id=name_by_id, applies=applies ) team_user = _FakeTeamUser( id_=101, @@ -221,6 +229,62 @@ def test_revoke_already_revoked_logs_failure(self): ) 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):