From 4193eee0074b594cefbd7bd78c701584002eeea4 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Fri, 29 May 2026 09:40:47 -0700 Subject: [PATCH 01/18] added ability to set grid authorization type field to CurationTask --- .../curator/file_based_metadata_task.py | 19 +++- .../curator/record_based_metadata_task.py | 21 +++- synapseclient/models/__init__.py | 2 + synapseclient/models/curation.py | 106 ++++++++++++++++-- 4 files changed, 130 insertions(+), 18 deletions(-) diff --git a/synapseclient/extensions/curator/file_based_metadata_task.py b/synapseclient/extensions/curator/file_based_metadata_task.py index cd2a39191..af64368d4 100644 --- a/synapseclient/extensions/curator/file_based_metadata_task.py +++ b/synapseclient/extensions/curator/file_based_metadata_task.py @@ -12,6 +12,7 @@ from synapseclient.core.exceptions import SynapseHTTPError # type: ignore from synapseclient.extensions.curator.utils import project_id_from_entity_id from synapseclient.models import ( # type: ignore + AuthorizationMode, Column, ColumnType, EntityView, @@ -325,6 +326,8 @@ def create_file_based_metadata_task( enable_derived_annotations: bool = False, assignee_principal_id: Optional[Union[str, int]] = None, view_type_mask: Union[int, ViewTypeMask] = ViewTypeMask.FILE, + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None, + collaborator_principal_ids: Optional[list[str]] = None, *, synapse_client: Optional[Synapse] = None, ) -> Tuple[str, str]: @@ -338,7 +341,7 @@ def create_file_based_metadata_task( ```python import synapseclient from synapseclient.extensions.curator import create_file_based_metadata_task - from synapseclient.models import ViewTypeMask + from synapseclient.models import AuthorizationMode, ViewTypeMask syn = synapseclient.Synapse() syn.login() @@ -351,8 +354,9 @@ def create_file_based_metadata_task( attach_wiki=False, entity_view_name="Biospecimen Metadata View", schema_uri="sage.schemas.v2571-amp.Biospecimen.schema-0.0.1", - assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) - view_type_mask=ViewTypeMask.FILE | ViewTypeMask.DOCKER, # Optional: include additional entity types in the view + assignee_principal_id=123456, + view_type_mask=ViewTypeMask.FILE | ViewTypeMask.DOCKER, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, ) ``` @@ -377,6 +381,13 @@ def create_file_based_metadata_task( ViewTypeMask.FILE. Additional types can be added using bitwise OR (e.g., ViewTypeMask.FILE | ViewTypeMask.DOCKER). Accepts either a ViewTypeMask enum member or its raw integer value. + suggested_authorization_mode: The authorization mode a client should use when + creating a linked grid session for this task. When omitted, clients follow + legacy behavior: find or create a personal, unlinked grid session. + collaborator_principal_ids: The set of principal IDs that should collaborate on + the grid session. Used to set the owner(s) of a linked GridSession when + suggested_authorization_mode is SESSION_OWNER. Reserved for future + multi-owner support; not actively used at this time. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -481,6 +492,8 @@ def create_file_based_metadata_task( task_properties=FileBasedMetadataTaskProperties( upload_folder_id=folder_id, file_view_id=entity_view_id, + suggested_authorization_mode=suggested_authorization_mode, + collaborator_principal_ids=collaborator_principal_ids, ), ).store(synapse_client=synapse_client) except Exception as e: diff --git a/synapseclient/extensions/curator/record_based_metadata_task.py b/synapseclient/extensions/curator/record_based_metadata_task.py index c97a49260..ee6413239 100644 --- a/synapseclient/extensions/curator/record_based_metadata_task.py +++ b/synapseclient/extensions/curator/record_based_metadata_task.py @@ -14,6 +14,7 @@ from synapseclient.core.utils import test_import_pandas from synapseclient.extensions.curator.utils import project_id_from_entity_id from synapseclient.models import ( + AuthorizationMode, CurationTask, Grid, JSONSchema, @@ -111,6 +112,8 @@ def create_record_based_metadata_task( bind_schema_to_record_set: bool = True, enable_derived_annotations: bool = False, assignee_principal_id: Optional[Union[str, int]] = None, + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None, + collaborator_principal_ids: Optional[list[str]] = None, *, synapse_client: Optional[Synapse] = None, project_id: Optional[str] = None, # Deprecated, will be removed in v5.0.0 @@ -136,13 +139,13 @@ def create_record_based_metadata_task( Example: Creating a record-based metadata curation task with a schema URI In this example, we create a RecordSet and CurationTask for biospecimen metadata curation using a schema URI. By default this will also bind the schema to the - RecordSet, however the `bind_schema_to_record_set` parameter can be set to + RecordSet, however the bind_schema_to_record_set parameter can be set to False to skip that step. - ```python import synapseclient from synapseclient.extensions.curator import create_record_based_metadata_task + from synapseclient.models import AuthorizationMode syn = synapseclient.Synapse() syn.login() @@ -156,8 +159,9 @@ def create_record_based_metadata_task( upsert_keys=["specimenID"], instructions="Please curate this metadata according to the schema requirements", schema_uri="schema-org-schema.name.schema-v1.0.0", - assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) - create_grid=False, # Opt out of deprecated Grid creation + assignee_principal_id=123456, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + create_grid=False, ) ``` @@ -182,6 +186,13 @@ def create_record_based_metadata_task( (default), the task will be unassigned. For metadata tasks, this determines the owner of the grid session. Team members can all join grid sessions owned by their team, while user-owned grid sessions are restricted to that user only. + suggested_authorization_mode: The authorization mode a client should use when + creating a linked grid session for this task. When omitted, clients follow + legacy behavior: find or create a personal, unlinked grid session. + collaborator_principal_ids: The set of principal IDs that should collaborate on + the grid session. Used to set the owner(s) of a linked GridSession when + suggested_authorization_mode is SESSION_OWNER. Reserved for future + multi-owner support; not actively used at this time. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -286,6 +297,8 @@ def create_record_based_metadata_task( ), task_properties=RecordBasedMetadataTaskProperties( record_set_id=record_set_id, + suggested_authorization_mode=suggested_authorization_mode, + collaborator_principal_ids=collaborator_principal_ids, ), ).store(synapse_client=synapse_client) synapse_client.logger.info( diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 0badee2db..a5324acb9 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -8,6 +8,7 @@ ) from synapseclient.models.annotations import Annotations from synapseclient.models.curation import ( + AuthorizationMode, CurationTask, FileBasedMetadataTaskProperties, Grid, @@ -94,6 +95,7 @@ "Team", "TeamMember", "TeamMembershipStatus", + "AuthorizationMode", "CurationTask", "FileBasedMetadataTaskProperties", "RecordBasedMetadataTaskProperties", diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 8eb110c31..b1852f132 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -10,7 +10,16 @@ from dataclasses import dataclass, field, replace from datetime import datetime, timezone from enum import Enum -from typing import Any, AsyncGenerator, Dict, Generator, Optional, Protocol, Union +from typing import ( + Any, + AsyncGenerator, + ClassVar, + Dict, + Generator, + Optional, + Protocol, + Union, +) from opentelemetry import trace @@ -52,6 +61,7 @@ merge_dataclass_entities, ) from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator +from synapseclient.models.mixins.enum_coercion import EnumCoercionMixin from synapseclient.models.recordset import ValidationSummary from synapseclient.models.table_components import Column, CsvTableDescriptor, Query @@ -76,8 +86,23 @@ class TaskState(str, Enum): """The task has been canceled and is no longer needed.""" +class AuthorizationMode(str, Enum): + """ + The authorization mode a client should use when creating a linked grid session + for a CurationTask. + + See . + """ + + SESSION_OWNER = "SESSION_OWNER" + """The grid session is owned by one or more explicit principals (collaborator_principal_ids).""" + + SOURCE_BENEFACTOR = "SOURCE_BENEFACTOR" + """The grid session inherits permissions from the benefactor of the source entity.""" + + @dataclass -class FileBasedMetadataTaskProperties: +class FileBasedMetadataTaskProperties(EnumCoercionMixin): """ A CurationTaskProperties for file-based data, describing where data is uploaded and a view which contains the annotations. @@ -89,12 +114,28 @@ class FileBasedMetadataTaskProperties: file_view_id: The synId of the FileView that shows all data of this type """ + _ENUM_FIELDS: ClassVar[dict[str, type]] = { + "suggested_authorization_mode": AuthorizationMode + } + upload_folder_id: Optional[str] = None """The synId of the folder where data files of this type are to be uploaded""" file_view_id: Optional[str] = None """The synId of the FileView that shows all data of this type""" + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None + """The authorization mode a client should use when creating a linked grid session for + this task. When omitted, clients follow legacy behavior: find or create a personal, + unlinked grid session. When this field changes, the server automatically clears + activeSessionId from the task status. Accepts either an AuthorizationMode enum + value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" + + collaborator_principal_ids: Optional[list[str]] = None + """The set of principal IDs that should collaborate on the grid session. Used to set + the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER. + Reserved for future multi-owner support; not actively used at this time.""" + def fill_from_dict( self, synapse_response: Union[Dict[str, Any], Any] ) -> "FileBasedMetadataTaskProperties": @@ -109,6 +150,12 @@ def fill_from_dict( """ self.upload_folder_id = synapse_response.get("uploadFolderId", None) self.file_view_id = synapse_response.get("fileViewId", None) + self.suggested_authorization_mode = synapse_response.get( + "suggestedAuthorizationMode", None + ) + self.collaborator_principal_ids = synapse_response.get( + "collaboratorPrincipalIds", None + ) return self def to_synapse_request(self) -> Dict[str, Any]: @@ -118,16 +165,23 @@ def to_synapse_request(self) -> Dict[str, Any]: Returns: A dictionary representation of this object for API requests. """ - request_dict = {"concreteType": FILE_BASED_METADATA_TASK_PROPERTIES} - if self.upload_folder_id is not None: - request_dict["uploadFolderId"] = self.upload_folder_id - if self.file_view_id is not None: - request_dict["fileViewId"] = self.file_view_id + request_dict = { + "concreteType": FILE_BASED_METADATA_TASK_PROPERTIES, + "uploadFolderId": self.upload_folder_id, + "fileViewId": self.file_view_id, + "suggestedAuthorizationMode": ( + self.suggested_authorization_mode.value + if self.suggested_authorization_mode is not None + else None + ), + "collaboratorPrincipalIds": self.collaborator_principal_ids, + } + delete_none_keys(request_dict) return request_dict @dataclass -class RecordBasedMetadataTaskProperties: +class RecordBasedMetadataTaskProperties(EnumCoercionMixin): """ A CurationTaskProperties for record-based metadata. @@ -137,9 +191,25 @@ class RecordBasedMetadataTaskProperties: record_set_id: The synId of the RecordSet that will contain all record-based metadata """ + _ENUM_FIELDS: ClassVar[Dict[str, type]] = { + "suggested_authorization_mode": AuthorizationMode + } + record_set_id: Optional[str] = None """The synId of the RecordSet that will contain all record-based metadata""" + suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None + """The authorization mode a client should use when creating a linked grid session for + this task. When omitted, clients follow legacy behavior: find or create a personal, + unlinked grid session. When this field changes, the server automatically clears + activeSessionId from the task status. Accepts either an AuthorizationMode enum + value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" + + collaborator_principal_ids: Optional[list[str]] = None + """The set of principal IDs that should collaborate on the grid session. Used to set + the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER. + Reserved for future multi-owner support; not actively used at this time.""" + def fill_from_dict( self, synapse_response: Union[Dict[str, Any], Any] ) -> "RecordBasedMetadataTaskProperties": @@ -153,6 +223,12 @@ def fill_from_dict( The RecordBasedMetadataTaskProperties object. """ self.record_set_id = synapse_response.get("recordSetId", None) + self.suggested_authorization_mode = synapse_response.get( + "suggestedAuthorizationMode", None + ) + self.collaborator_principal_ids = synapse_response.get( + "collaboratorPrincipalIds", None + ) return self def to_synapse_request(self) -> Dict[str, Any]: @@ -162,9 +238,17 @@ def to_synapse_request(self) -> Dict[str, Any]: Returns: A dictionary representation of this object for API requests. """ - request_dict = {"concreteType": RECORD_BASED_METADATA_TASK_PROPERTIES} - if self.record_set_id is not None: - request_dict["recordSetId"] = self.record_set_id + request_dict = { + "concreteType": RECORD_BASED_METADATA_TASK_PROPERTIES, + "recordSetId": self.record_set_id, + "suggestedAuthorizationMode": ( + self.suggested_authorization_mode.value + if self.suggested_authorization_mode is not None + else None + ), + "collaboratorPrincipalIds": self.collaborator_principal_ids, + } + delete_none_keys(request_dict) return request_dict From ed7bd17529bc32b165df6c8051105aaaca771672 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Fri, 29 May 2026 09:47:07 -0700 Subject: [PATCH 02/18] add back in comments --- synapseclient/extensions/curator/file_based_metadata_task.py | 4 ++-- .../extensions/curator/record_based_metadata_task.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/synapseclient/extensions/curator/file_based_metadata_task.py b/synapseclient/extensions/curator/file_based_metadata_task.py index af64368d4..1cfc1f2e2 100644 --- a/synapseclient/extensions/curator/file_based_metadata_task.py +++ b/synapseclient/extensions/curator/file_based_metadata_task.py @@ -354,8 +354,8 @@ def create_file_based_metadata_task( attach_wiki=False, entity_view_name="Biospecimen Metadata View", schema_uri="sage.schemas.v2571-amp.Biospecimen.schema-0.0.1", - assignee_principal_id=123456, - view_type_mask=ViewTypeMask.FILE | ViewTypeMask.DOCKER, + assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) + view_type_mask=ViewTypeMask.FILE | ViewTypeMask.DOCKER, # Optional: include additional entity types in the view suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, ) ``` diff --git a/synapseclient/extensions/curator/record_based_metadata_task.py b/synapseclient/extensions/curator/record_based_metadata_task.py index ee6413239..68832e0c4 100644 --- a/synapseclient/extensions/curator/record_based_metadata_task.py +++ b/synapseclient/extensions/curator/record_based_metadata_task.py @@ -159,9 +159,9 @@ def create_record_based_metadata_task( upsert_keys=["specimenID"], instructions="Please curate this metadata according to the schema requirements", schema_uri="schema-org-schema.name.schema-v1.0.0", - assignee_principal_id=123456, + assignee_principal_id=123456, # Optional: Assign to a user or team (can be str or int) suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, - create_grid=False, + create_grid=False, # Opt out of deprecated Grid creation ) ``` From 7ac942fcd1559342afb0961da14c21e0b5f8f0c6 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 07:33:42 -0700 Subject: [PATCH 03/18] added authorizationMode to CurationTask and Grid --- .../extensions/curator/metadata_curation.md | 26 +++ docs/reference/experimental/async/curator.md | 6 + docs/reference/experimental/sync/curator.md | 6 + .../curator/file_based_metadata_task.py | 11 +- .../curator/record_based_metadata_task.py | 11 +- synapseclient/models/curation.py | 90 ++++++++-- .../extensions/unit_test_curator.py | 128 ++++++++++++++ .../models/async/unit_test_curation_async.py | 161 +++++++++++++++++- 8 files changed, 408 insertions(+), 31 deletions(-) diff --git a/docs/guides/extensions/curator/metadata_curation.md b/docs/guides/extensions/curator/metadata_curation.md index 2cb023df4..f84407aad 100644 --- a/docs/guides/extensions/curator/metadata_curation.md +++ b/docs/guides/extensions/curator/metadata_curation.md @@ -131,6 +131,32 @@ print(f"Created CurationTask: {task_id}") - Automatic schema binding to the folder for validation - Optional wiki attached to the folder +### Controlling who can access the grid session + +Both `create_record_based_metadata_task` and `create_file_based_metadata_task` accept an optional `suggested_authorization_mode` that tells clients how to scope access when a grid session is created for the task: + +- `AuthorizationMode.SESSION_OWNER` (the default behavior when the mode is omitted) limits access to the session owner and their team. Use it when curation should be restricted to a specific user or team. +- `AuthorizationMode.SOURCE_BENEFACTOR` extends access to anyone with `EDIT` rights on the source entity. Use it when curation should be open to all editors of the source. + +```python +from synapseclient.models import AuthorizationMode + +entity_view_id, task_id = create_file_based_metadata_task( + synapse_client=syn, + folder_id="syn987654321", + curation_task_name="FileMetadata_Curation", + instructions="Annotate each file with metadata according to the schema requirements.", + entity_view_name="Animal Study Files View", + schema_uri=schema_uri, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, +) +``` + +When [CurationTask.create_grid_session][synapseclient.models.CurationTask.create_grid_session] is later called, it forwards this mode to the new grid session and the server uses it to determine access: `SESSION_OWNER` limits access to the session owner (the caller, or an explicit `owner_principal_id`) and their team, while `SOURCE_BENEFACTOR` lets the session inherit access from the source entity's benefactor. + +!!! warning "Changing the mode invalidates the active session" + Updating `suggested_authorization_mode` on an existing task causes the server to automatically clear `activeSessionId` on the task status. The previously active grid session is no longer linked to the task, so you must create a new grid session before curation can continue. + ## Complete example script Here's the full script that demonstrates both workflow types: diff --git a/docs/reference/experimental/async/curator.md b/docs/reference/experimental/async/curator.md index 1b1654e2a..6908bfa9f 100644 --- a/docs/reference/experimental/async/curator.md +++ b/docs/reference/experimental/async/curator.md @@ -54,6 +54,12 @@ at your own risk. inherited_members: true members: --- +[](){ #AuthorizationMode-reference-async } +::: synapseclient.models.AuthorizationMode + options: + inherited_members: true + members: +--- [](){ #grid-reference-async } ::: synapseclient.models.Grid options: diff --git a/docs/reference/experimental/sync/curator.md b/docs/reference/experimental/sync/curator.md index f0866edef..ad5a3eab0 100644 --- a/docs/reference/experimental/sync/curator.md +++ b/docs/reference/experimental/sync/curator.md @@ -54,6 +54,12 @@ at your own risk. inherited_members: true members: --- +[](){ #AuthorizationMode-reference } +::: synapseclient.models.AuthorizationMode + options: + inherited_members: true + members: +--- [](){ #grid-reference } ::: synapseclient.models.Grid options: diff --git a/synapseclient/extensions/curator/file_based_metadata_task.py b/synapseclient/extensions/curator/file_based_metadata_task.py index 1cfc1f2e2..ea5bafde1 100644 --- a/synapseclient/extensions/curator/file_based_metadata_task.py +++ b/synapseclient/extensions/curator/file_based_metadata_task.py @@ -327,7 +327,6 @@ def create_file_based_metadata_task( assignee_principal_id: Optional[Union[str, int]] = None, view_type_mask: Union[int, ViewTypeMask] = ViewTypeMask.FILE, suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None, - collaborator_principal_ids: Optional[list[str]] = None, *, synapse_client: Optional[Synapse] = None, ) -> Tuple[str, str]: @@ -384,10 +383,11 @@ def create_file_based_metadata_task( suggested_authorization_mode: The authorization mode a client should use when creating a linked grid session for this task. When omitted, clients follow legacy behavior: find or create a personal, unlinked grid session. - collaborator_principal_ids: The set of principal IDs that should collaborate on - the grid session. Used to set the owner(s) of a linked GridSession when - suggested_authorization_mode is SESSION_OWNER. Reserved for future - multi-owner support; not actively used at this time. + SESSION_OWNER limits access to the session owner and their team; + SOURCE_BENEFACTOR extends access to anyone with EDIT rights on the source + entity. Note that changing suggested_authorization_mode after the task has + been created causes the server to clear activeSessionId on the task status, + so a new grid session must be created before curation can continue. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -493,7 +493,6 @@ def create_file_based_metadata_task( upload_folder_id=folder_id, file_view_id=entity_view_id, suggested_authorization_mode=suggested_authorization_mode, - collaborator_principal_ids=collaborator_principal_ids, ), ).store(synapse_client=synapse_client) except Exception as e: diff --git a/synapseclient/extensions/curator/record_based_metadata_task.py b/synapseclient/extensions/curator/record_based_metadata_task.py index 68832e0c4..1e05e8d46 100644 --- a/synapseclient/extensions/curator/record_based_metadata_task.py +++ b/synapseclient/extensions/curator/record_based_metadata_task.py @@ -113,7 +113,6 @@ def create_record_based_metadata_task( enable_derived_annotations: bool = False, assignee_principal_id: Optional[Union[str, int]] = None, suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None, - collaborator_principal_ids: Optional[list[str]] = None, *, synapse_client: Optional[Synapse] = None, project_id: Optional[str] = None, # Deprecated, will be removed in v5.0.0 @@ -189,10 +188,11 @@ def create_record_based_metadata_task( suggested_authorization_mode: The authorization mode a client should use when creating a linked grid session for this task. When omitted, clients follow legacy behavior: find or create a personal, unlinked grid session. - collaborator_principal_ids: The set of principal IDs that should collaborate on - the grid session. Used to set the owner(s) of a linked GridSession when - suggested_authorization_mode is SESSION_OWNER. Reserved for future - multi-owner support; not actively used at this time. + SESSION_OWNER limits access to the session owner and their team; + SOURCE_BENEFACTOR extends access to anyone with EDIT rights on the source + entity. Note that changing suggested_authorization_mode after the task has + been created causes the server to clear activeSessionId on the task status, + so a new grid session must be created before curation can continue. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -298,7 +298,6 @@ def create_record_based_metadata_task( task_properties=RecordBasedMetadataTaskProperties( record_set_id=record_set_id, suggested_authorization_mode=suggested_authorization_mode, - collaborator_principal_ids=collaborator_principal_ids, ), ).store(synapse_client=synapse_client) synapse_client.logger.info( diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 54c0abac0..36d521fdc 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -100,10 +100,16 @@ class AuthorizationMode(str, Enum): """ SESSION_OWNER = "SESSION_OWNER" - """The grid session is owned by one or more explicit principals (collaborator_principal_ids).""" + """Access is limited to the session owner or members of the owner's team. This is + the default setting. When a view serves as the source, the owner can access all + available rows, while other team members see data according to the owner's + permission scope.""" SOURCE_BENEFACTOR = "SOURCE_BENEFACTOR" - """The grid session inherits permissions from the benefactor of the source entity.""" + """Access is granted to any user who has EDIT (UPDATE) access on all benefactor IDs + captured when the session was created. This mode allows project administrators to + enable collaborative grid access for all editors without maintaining a separate + ownership team. User visibility of rows depends on their individual permissions.""" @dataclass @@ -131,15 +137,18 @@ class FileBasedMetadataTaskProperties(EnumCoercionMixin): suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None """The authorization mode a client should use when creating a linked grid session for - this task. When omitted, clients follow legacy behavior: find or create a personal, - unlinked grid session. When this field changes, the server automatically clears - activeSessionId from the task status. Accepts either an AuthorizationMode enum - value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" + this task. SESSION_OWNER limits access to the session owner and their team; use it + when curation should be scoped to a specific user or team. SOURCE_BENEFACTOR extends + access to anyone with EDIT rights on the source entity; use it when curation should + be open to all editors of the source. When omitted, clients follow legacy behavior: + find or create a personal, unlinked grid session. When this field changes, the server + automatically clears activeSessionId from the task status. Accepts either an + AuthorizationMode enum value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" collaborator_principal_ids: Optional[list[str]] = None - """The set of principal IDs that should collaborate on the grid session. Used to set - the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER. - Reserved for future multi-owner support; not actively used at this time.""" + """Not actively used at this time. + The set of principal IDs that should collaborate on the grid session. Used to set + the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER""" def fill_from_dict( self, synapse_response: Union[Dict[str, Any], Any] @@ -205,15 +214,18 @@ class RecordBasedMetadataTaskProperties(EnumCoercionMixin): suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None """The authorization mode a client should use when creating a linked grid session for - this task. When omitted, clients follow legacy behavior: find or create a personal, - unlinked grid session. When this field changes, the server automatically clears - activeSessionId from the task status. Accepts either an AuthorizationMode enum - value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" + this task. SESSION_OWNER limits access to the session owner and their team; use it + when curation should be scoped to a specific user or team. SOURCE_BENEFACTOR extends + access to anyone with EDIT rights on the source entity; use it when curation should + be open to all editors of the source. When omitted, clients follow legacy behavior: + find or create a personal, unlinked grid session. When this field changes, the server + automatically clears activeSessionId from the task status. Accepts either an + AuthorizationMode enum value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" collaborator_principal_ids: Optional[list[str]] = None - """The set of principal IDs that should collaborate on the grid session. Used to set - the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER. - Reserved for future multi-owner support; not actively used at this time.""" + """Not actively used at this time. + The set of principal IDs that should collaborate on the grid session. Used to set + the owner(s) of a linked GridSession when suggested_authorization_mode is SESSION_OWNER""" def fill_from_dict( self, synapse_response: Union[Dict[str, Any], Any] @@ -704,6 +716,15 @@ def create_grid_session( Always creates a new Grid session. To attach an existing session to a task, use set_active_grid_session instead. + The new session is created with the task's suggested_authorization_mode + (from task_properties), which the server uses to determine access: + + - SESSION_OWNER: access is limited to the session owner (owner_principal_id, + or the caller when not provided) and their team. + - SOURCE_BENEFACTOR: access is inherited from the benefactor of the source + entity (anyone with EDIT rights). + - Unset (legacy): the caller becomes the owner. + After the Grid is created, updates the CurationTaskStatus to point its active_session_id at the new session. If that update fails for any reason, the newly created Grid is deleted on a best-effort basis and the original @@ -1728,6 +1749,15 @@ async def create_grid_session_async( Always creates a new Grid session. To attach an existing session to a task, use set_active_grid_session_async instead. + The new session is created with the task's suggested_authorization_mode + (from task_properties), which the server uses to determine access: + + - SESSION_OWNER: access is limited to the session owner (owner_principal_id, + or the caller when not provided) and their team. + - SOURCE_BENEFACTOR: access is inherited from the benefactor of the source + entity (anyone with EDIT rights). + - Unset (legacy): the caller becomes the owner. + After the Grid is created, updates the CurationTaskStatus to point its active_session_id at the new session. If that update fails for any reason, the newly created Grid is deleted on a best-effort basis and the original @@ -1792,6 +1822,7 @@ async def main(): grid = Grid( record_set_id=self.task_properties.record_set_id, owner_principal_id=owner_principal_id, + authorization_mode=self.task_properties.suggested_authorization_mode, ) elif isinstance(self.task_properties, FileBasedMetadataTaskProperties): if not self.task_properties.file_view_id: @@ -1810,6 +1841,7 @@ async def main(): sql=f"SELECT * FROM {self.task_properties.file_view_id}" ), owner_principal_id=owner_principal_id, + authorization_mode=self.task_properties.suggested_authorization_mode, ) else: raise ValueError( @@ -2018,7 +2050,7 @@ async def main(): @dataclass -class CreateGridRequest(AsynchronousCommunicator): +class CreateGridRequest(EnumCoercionMixin, AsynchronousCommunicator): """ Start a job to create a new Grid session. @@ -2035,6 +2067,8 @@ class CreateGridRequest(AsynchronousCommunicator): In order to allow other users to access the grid, set this value to the id of a team. When a team ID is provided as the owner, all members of that team will have equal access to the grid. Note: If a team ID is provided, the creator of the grid must be a member of the team. + authorization_mode: Controls access permissions and row visibility at session + creation time. See AuthorizationMode. Defaults to SESSION_OWNER when omitted. session_id: The session ID of the created grid (populated from response) """ @@ -2057,9 +2091,15 @@ class CreateGridRequest(AsynchronousCommunicator): When a team ID is provided as the owner, all members of that team will have equal access to the grid. Note: If a team ID is provided, the creator of the grid must be a member of the team.""" + authorization_mode: Optional[AuthorizationMode] = None + """Controls access permissions and row visibility at session creation time. + See AuthorizationMode. When omitted, the service defaults to SESSION_OWNER.""" + session_id: Optional[str] = None """The session ID of the created grid (populated from response)""" + _ENUM_FIELDS: ClassVar[Dict[str, type]] = {"authorization_mode": AuthorizationMode} + _grid_session_data: Optional[Dict[str, Any]] = field(default=None, compare=False) """Internal storage of the full grid session data from the response for later use.""" @@ -2125,6 +2165,9 @@ def to_synapse_request(self) -> Dict[str, Any]: self.initial_query.to_synapse_request() if self.initial_query else None ) request_dict["ownerPrincipalId"] = self.owner_principal_id + request_dict["authorizationMode"] = ( + self.authorization_mode.value if self.authorization_mode else None + ) delete_none_keys(request_dict) return request_dict @@ -3007,7 +3050,7 @@ def import_csv( @dataclass @async_to_sync -class Grid(GridSynchronousProtocol): +class Grid(EnumCoercionMixin, GridSynchronousProtocol): """ A GridSession provides functionality to create and manage grid sessions in Synapse. Grid sessions are used for curation workflows where data can be edited in a grid format @@ -3020,6 +3063,9 @@ class Grid(GridSynchronousProtocol): owner_principal_id: The principal ID (user or team) that will own the created grid session. When not provided, the principal ID of the caller is used. + authorization_mode: Controls access permissions and row visibility at + session creation time. See AuthorizationMode. When not provided, the + service default (SESSION_OWNER) is used. session_id: The unique sessionId that identifies the grid session started_by: The user that started this session started_on: The date-time when the session was started @@ -3088,6 +3134,11 @@ class Grid(GridSynchronousProtocol): """The principal ID (user or team) that will own the created grid session. When not provided, the principal ID of the caller is used.""" + authorization_mode: Optional[AuthorizationMode] = None + """Controls access permissions and row visibility at session creation time. + See AuthorizationMode. When not provided, the service default (SESSION_OWNER) + is used.""" + session_id: Optional[str] = None """The unique sessionId that identifies the grid session""" @@ -3121,6 +3172,8 @@ class Grid(GridSynchronousProtocol): validation_summary_statistics: Optional[ValidationSummary] = None """Summary statistics for validation results""" + _ENUM_FIELDS: ClassVar[Dict[str, type]] = {"authorization_mode": AuthorizationMode} + async def create_async( self, attach_to_previous_session=False, @@ -3206,6 +3259,7 @@ async def main(): record_set_id=self.record_set_id, initial_query=self.initial_query, owner_principal_id=self.owner_principal_id, + authorization_mode=self.authorization_mode, ) result = await create_request.send_job_and_wait_async( timeout=timeout, synapse_client=synapse_client diff --git a/tests/unit/synapseclient/extensions/unit_test_curator.py b/tests/unit/synapseclient/extensions/unit_test_curator.py index de0273b1c..5007e3844 100644 --- a/tests/unit/synapseclient/extensions/unit_test_curator.py +++ b/tests/unit/synapseclient/extensions/unit_test_curator.py @@ -54,6 +54,7 @@ ) from synapseclient.models import Column, ColumnType, ViewTypeMask from synapseclient.models.curation import ( + AuthorizationMode, FileBasedMetadataTaskProperties, RecordBasedMetadataTaskProperties, ) @@ -484,6 +485,62 @@ def test_create_file_based_metadata_task_with_assignee( view_type_mask=ViewTypeMask.FILE, ) + @patch( + "synapseclient.extensions.curator.file_based_metadata_task.project_id_from_entity_id" + ) + @patch( + "synapseclient.extensions.curator.file_based_metadata_task.Synapse.get_client" + ) + @patch( + "synapseclient.extensions.curator.file_based_metadata_task.create_json_schema_entity_view" + ) + @patch("synapseclient.extensions.curator.file_based_metadata_task.Folder") + @patch("synapseclient.extensions.curator.file_based_metadata_task.CurationTask") + def test_create_file_based_metadata_task_with_authorization_mode( + self, + mock_curation_task_cls, + mock_folder_cls, + mock_create_entity_view, + mock_get_client, + mock_get_project_id_from_entity_id, + ): + """The authorization params are forwarded into FileBasedMetadataTaskProperties.""" + # GIVEN a file-based metadata task with authorization params + mock_get_client.return_value = self.mock_syn + mock_create_entity_view.return_value = "test_entity_view_id" + mock_get_project_id_from_entity_id.return_value = self.project_id + + mock_folder = Mock() + mock_folder_cls.return_value = mock_folder + mock_folder.get.return_value = mock_folder + mock_folder.parent_id = "syn11111111" + + mock_task = Mock() + mock_task.task_id = "task123" + mock_curation_task = Mock() + mock_curation_task.store.return_value = mock_task + mock_curation_task_cls.return_value = mock_curation_task + + # WHEN I create the task with an authorization mode + create_file_based_metadata_task( + folder_id=self.folder_id, + curation_task_name=self.curation_task_name, + instructions=self.instructions, + attach_wiki=False, + entity_view_name=self.entity_view_name, + schema_uri=self.schema_uri, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + synapse_client=self.mock_syn, + ) + + # THEN the CurationTask is built with that mode on its task_properties + _, kwargs = mock_curation_task_cls.call_args + assert kwargs["task_properties"] == FileBasedMetadataTaskProperties( + upload_folder_id=self.folder_id, + file_view_id="test_entity_view_id", + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + class TestCreateRecordBasedMetadataTask(unittest.TestCase): """Test cases for create_record_based_metadata_task function.""" @@ -606,6 +663,77 @@ def test_create_record_based_metadata_task_success( "A Grid object will no longer be created by this function starting in v5.0.0." ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.project_id_from_entity_id" + ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.Synapse.get_client" + ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.extract_schema_properties_from_web" + ) + @patch( + "synapseclient.extensions.curator.record_based_metadata_task.tempfile.NamedTemporaryFile" + ) + @patch("synapseclient.extensions.curator.record_based_metadata_task.RecordSet") + @patch("synapseclient.extensions.curator.record_based_metadata_task.CurationTask") + @patch("synapseclient.extensions.curator.record_based_metadata_task.Grid") + @patch("builtins.open") + def test_create_record_based_metadata_task_with_authorization_mode( + self, + mock_open, + mock_grid_cls, + mock_curation_task_cls, + mock_record_set_cls, + mock_temp_file, + mock_extract_schema, + mock_get_client, + mock_get_project_id_from_entity_id, + ): + """The authorization params are forwarded into RecordBasedMetadataTaskProperties.""" + # GIVEN a record-based metadata task with authorization params + mock_get_client.return_value = self.mock_syn + mock_get_project_id_from_entity_id.return_value = self.project_id + + mock_extract_schema.return_value = pd.DataFrame(columns=["specimenID"]) + + mock_temp = Mock() + mock_temp.name = "/tmp/test.csv" + mock_temp_file.return_value = mock_temp + + mock_record_set = Mock() + mock_record_set.id = "syn87654321" + mock_record_set_instance = Mock() + mock_record_set_instance.store.return_value = mock_record_set + mock_record_set_cls.return_value = mock_record_set_instance + + mock_task = Mock() + mock_task.task_id = "task123" + mock_curation_task = Mock() + mock_curation_task.store.return_value = mock_task + mock_curation_task_cls.return_value = mock_curation_task + + # WHEN I create the task with an authorization mode + create_record_based_metadata_task( + folder_id=self.folder_id, + record_set_name=self.record_set_name, + record_set_description=self.record_set_description, + curation_task_name=self.curation_task_name, + upsert_keys=self.upsert_keys, + instructions=self.instructions, + schema_uri=self.schema_uri, + suggested_authorization_mode="SESSION_OWNER", + create_grid=False, + synapse_client=self.mock_syn, + ) + + # THEN the CurationTask is built with that mode on its task_properties + _, kwargs = mock_curation_task_cls.call_args + assert kwargs["task_properties"] == RecordBasedMetadataTaskProperties( + record_set_id="syn87654321", + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, + ) + @patch( "synapseclient.extensions.curator.record_based_metadata_task.project_id_from_entity_id" ) diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index 99f3d49b4..df4f62dc2 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -15,6 +15,7 @@ ) from synapseclient.models import EntityView, RecordSet from synapseclient.models.curation import ( + AuthorizationMode, CreateGridRequest, CurationTask, CurationTaskStatus, @@ -177,9 +178,44 @@ def test_to_synapse_request_none_values(self) -> None: # WHEN I convert it to a request dict request = props.to_synapse_request() - # THEN the request should only contain concreteType + # THEN the request should only contain concreteType (all None-valued fields, + # including suggested_authorization_mode, are dropped) assert request == {"concreteType": FILE_BASED_METADATA_TASK_PROPERTIES} + def test_fill_from_dict_authorization_fields(self) -> None: + # GIVEN a response dict including the authorization fields + response = { + "uploadFolderId": UPLOAD_FOLDER_ID, + "fileViewId": FILE_VIEW_ID, + "suggestedAuthorizationMode": "SOURCE_BENEFACTOR", + "collaboratorPrincipalIds": ["111", "222"], + } + + # WHEN I fill a FileBasedMetadataTaskProperties from the dict + props = FileBasedMetadataTaskProperties() + props.fill_from_dict(response) + + # THEN the mode is coerced to the enum and collaborators pass through + assert props.suggested_authorization_mode == AuthorizationMode.SOURCE_BENEFACTOR + assert isinstance(props.suggested_authorization_mode, AuthorizationMode) + assert props.collaborator_principal_ids == ["111", "222"] + + def test_to_synapse_request_authorization_fields(self) -> None: + # GIVEN properties with the authorization mode supplied as a plain string + props = FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, + file_view_id=FILE_VIEW_ID, + suggested_authorization_mode="SESSION_OWNER", + collaborator_principal_ids=["111"], + ) + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the enum value is serialized as a string and collaborators pass through + assert request["suggestedAuthorizationMode"] == "SESSION_OWNER" + assert request["collaboratorPrincipalIds"] == ["111"] + class TestRecordBasedMetadataTaskProperties: """Tests for the RecordBasedMetadataTaskProperties dataclass.""" @@ -206,6 +242,38 @@ def test_to_synapse_request(self) -> None: assert request["concreteType"] == RECORD_BASED_METADATA_TASK_PROPERTIES assert request["recordSetId"] == RECORD_SET_ID + def test_fill_from_dict_authorization_fields(self) -> None: + # GIVEN a response dict including the authorization fields + response = { + "recordSetId": RECORD_SET_ID, + "suggestedAuthorizationMode": "SESSION_OWNER", + "collaboratorPrincipalIds": ["111"], + } + + # WHEN I fill a RecordBasedMetadataTaskProperties from the dict + props = RecordBasedMetadataTaskProperties() + props.fill_from_dict(response) + + # THEN the mode is coerced to the enum and collaborators pass through + assert props.suggested_authorization_mode == AuthorizationMode.SESSION_OWNER + assert isinstance(props.suggested_authorization_mode, AuthorizationMode) + assert props.collaborator_principal_ids == ["111"] + + def test_to_synapse_request_authorization_fields(self) -> None: + # GIVEN properties with an AuthorizationMode enum value set + props = RecordBasedMetadataTaskProperties( + record_set_id=RECORD_SET_ID, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the enum value is serialized as a string and the absent collaborators + # are dropped by delete_none_keys + assert request["suggestedAuthorizationMode"] == "SOURCE_BENEFACTOR" + assert "collaboratorPrincipalIds" not in request + class TestCreateTaskPropertiesFromDict: """Tests for the _create_task_properties_from_dict factory function.""" @@ -1093,6 +1161,97 @@ async def test_create_grid_session_async_unsupported_task_properties( ): await task.create_grid_session_async(synapse_client=self.syn) + async def test_create_grid_session_async_passes_authorization_mode_record_based( + self, + ) -> None: + """A record-based task forwards its suggested_authorization_mode to the Grid.""" + # GIVEN a record-based task in SESSION_OWNER mode + task = CurationTask( + task_id=TASK_ID, + task_properties=RecordBasedMetadataTaskProperties( + record_set_id=RECORD_SET_ID, + suggested_authorization_mode="SESSION_OWNER", + ), + ) + + with ( + patch.object(RecordSet, "get_async", new_callable=AsyncMock), + patch.object(task, "set_active_grid_session_async", new_callable=AsyncMock), + patch("synapseclient.models.curation.Grid") as mock_grid_cls, + ): + mock_grid = mock_grid_cls.return_value + mock_grid.session_id = SESSION_ID + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I create a grid session without an explicit owner + await task.create_grid_session_async(synapse_client=self.syn) + + # THEN the Grid is constructed with the task's authorization mode and the + # owner is left to the caller (None) for the server to resolve + kwargs = mock_grid_cls.call_args.kwargs + assert kwargs["authorization_mode"] == AuthorizationMode.SESSION_OWNER + assert kwargs["owner_principal_id"] is None + + async def test_create_grid_session_async_passes_authorization_mode_file_based( + self, + ) -> None: + """A file-based task forwards its suggested_authorization_mode to the Grid.""" + # GIVEN a file-based task in SOURCE_BENEFACTOR mode + task = CurationTask( + task_id=TASK_ID, + task_properties=FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, + file_view_id=FILE_VIEW_ID, + suggested_authorization_mode="SOURCE_BENEFACTOR", + ), + ) + + with ( + patch.object(EntityView, "get_async", new_callable=AsyncMock), + patch.object(task, "set_active_grid_session_async", new_callable=AsyncMock), + patch("synapseclient.models.curation.Grid") as mock_grid_cls, + ): + mock_grid = mock_grid_cls.return_value + mock_grid.session_id = SESSION_ID + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I create a grid session + await task.create_grid_session_async(synapse_client=self.syn) + + # THEN the Grid is constructed with the task's authorization mode + assert ( + mock_grid_cls.call_args.kwargs["authorization_mode"] + == AuthorizationMode.SOURCE_BENEFACTOR + ) + + async def test_create_grid_session_async_passes_explicit_owner(self) -> None: + """An explicit owner_principal_id is passed straight through to the Grid.""" + # GIVEN a record-based task + task = CurationTask( + task_id=TASK_ID, + task_properties=RecordBasedMetadataTaskProperties( + record_set_id=RECORD_SET_ID, + suggested_authorization_mode="SESSION_OWNER", + ), + ) + + with ( + patch.object(RecordSet, "get_async", new_callable=AsyncMock), + patch.object(task, "set_active_grid_session_async", new_callable=AsyncMock), + patch("synapseclient.models.curation.Grid") as mock_grid_cls, + ): + mock_grid = mock_grid_cls.return_value + mock_grid.session_id = SESSION_ID + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I create a grid session with an explicit owner + await task.create_grid_session_async( + owner_principal_id=555, synapse_client=self.syn + ) + + # THEN that owner is forwarded to the Grid unchanged + assert mock_grid_cls.call_args.kwargs["owner_principal_id"] == 555 + async def test_list_async_assigned_to_me_and_assignee_ids_raises(self) -> None: # GIVEN both assigned_to_me and assignee_ids are provided # WHEN I call list_async From 23d3b7808048313adf32e022b5ba338bf8f24458 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 07:37:22 -0700 Subject: [PATCH 04/18] fix typing --- synapseclient/models/curation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 36d521fdc..2f1ecbde3 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -205,7 +205,7 @@ class RecordBasedMetadataTaskProperties(EnumCoercionMixin): record_set_id: The synId of the RecordSet that will contain all record-based metadata """ - _ENUM_FIELDS: ClassVar[Dict[str, type]] = { + _ENUM_FIELDS: ClassVar[dict[str, type]] = { "suggested_authorization_mode": AuthorizationMode } @@ -2098,7 +2098,7 @@ class CreateGridRequest(EnumCoercionMixin, AsynchronousCommunicator): session_id: Optional[str] = None """The session ID of the created grid (populated from response)""" - _ENUM_FIELDS: ClassVar[Dict[str, type]] = {"authorization_mode": AuthorizationMode} + _ENUM_FIELDS: ClassVar[dict[str, type]] = {"authorization_mode": AuthorizationMode} _grid_session_data: Optional[Dict[str, Any]] = field(default=None, compare=False) """Internal storage of the full grid session data from the response for later use.""" @@ -3172,7 +3172,7 @@ class Grid(EnumCoercionMixin, GridSynchronousProtocol): validation_summary_statistics: Optional[ValidationSummary] = None """Summary statistics for validation results""" - _ENUM_FIELDS: ClassVar[Dict[str, type]] = {"authorization_mode": AuthorizationMode} + _ENUM_FIELDS: ClassVar[dict[str, type]] = {"authorization_mode": AuthorizationMode} async def create_async( self, From a0bfcbc3855f22a4bd34b9a729b65f0238b90fd6 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 07:42:25 -0700 Subject: [PATCH 05/18] fix how authorization mode is chnaged to a synapse request --- synapseclient/models/curation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 2f1ecbde3..f2a4bc43e 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -2166,7 +2166,9 @@ def to_synapse_request(self) -> Dict[str, Any]: ) request_dict["ownerPrincipalId"] = self.owner_principal_id request_dict["authorizationMode"] = ( - self.authorization_mode.value if self.authorization_mode else None + self.authorization_mode.value + if self.authorization_mode is not None + else None ) delete_none_keys(request_dict) return request_dict From 2445cb006567a53a5baf7951f0ce77f4f81d287d Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 08:21:35 -0700 Subject: [PATCH 06/18] Grid now gets authorizationMode --- synapseclient/models/curation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index f2a4bc43e..d02b3f890 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -2149,6 +2149,7 @@ def fill_grid_session_from_response(self, grid_session: "Grid") -> "Grid": grid_session.grid_json_schema_id = data.get("gridJsonSchema$Id", None) grid_session.source_entity_id = data.get("sourceEntityId", None) grid_session.owner_principal_id = data.get("ownerPrincipalId") + grid_session.authorization_mode = data.get("authorizationMode", None) return grid_session @@ -3350,6 +3351,7 @@ def fill_from_dict(self, synapse_response: Dict[str, Any]) -> "Grid": self.grid_json_schema_id = synapse_response.get("gridJsonSchema$Id", None) self.source_entity_id = synapse_response.get("sourceEntityId", None) self.owner_principal_id = synapse_response.get("ownerPrincipalId") + self.authorization_mode = synapse_response.get("authorizationMode", None) return self @skip_async_to_sync From eda799c23997d661297dbb537bc9584ac17ab4b2 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 08:26:39 -0700 Subject: [PATCH 07/18] fixed id type error --- synapseclient/models/curation.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index d02b3f890..7e5e86f8b 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -2148,7 +2148,10 @@ def fill_grid_session_from_response(self, grid_session: "Grid") -> "Grid": grid_session.last_replica_id_service = data.get("lastReplicaIdService", None) grid_session.grid_json_schema_id = data.get("gridJsonSchema$Id", None) grid_session.source_entity_id = data.get("sourceEntityId", None) - grid_session.owner_principal_id = data.get("ownerPrincipalId") + owner_principal_id = data.get("ownerPrincipalId") + grid_session.owner_principal_id = ( + int(owner_principal_id) if owner_principal_id is not None else None + ) grid_session.authorization_mode = data.get("authorizationMode", None) return grid_session @@ -3350,7 +3353,10 @@ def fill_from_dict(self, synapse_response: Dict[str, Any]) -> "Grid": ) self.grid_json_schema_id = synapse_response.get("gridJsonSchema$Id", None) self.source_entity_id = synapse_response.get("sourceEntityId", None) - self.owner_principal_id = synapse_response.get("ownerPrincipalId") + owner_principal_id = synapse_response.get("ownerPrincipalId") + self.owner_principal_id = ( + int(owner_principal_id) if owner_principal_id is not None else None + ) self.authorization_mode = synapse_response.get("authorizationMode", None) return self From e658121dfb5a8d58429d6bc55aa53168e3278909 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 08:26:54 -0700 Subject: [PATCH 08/18] added needed integration tests --- .../models/async/test_curation_async.py | 30 +++++++++++++++++-- .../models/async/test_grid_async.py | 21 +++++++++++++ .../models/async/unit_test_curation_async.py | 13 ++++++-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/tests/integration/synapseclient/models/async/test_curation_async.py b/tests/integration/synapseclient/models/async/test_curation_async.py index 9fd72cfa4..12948dded 100644 --- a/tests/integration/synapseclient/models/async/test_curation_async.py +++ b/tests/integration/synapseclient/models/async/test_curation_async.py @@ -12,6 +12,7 @@ from synapseclient.core.exceptions import SynapseHTTPError from synapseclient.core.utils import make_bogus_uuid_file from synapseclient.models import ( + AuthorizationMode, CurationTask, CurationTaskStatus, EntityView, @@ -163,6 +164,7 @@ async def test_store_record_based_curation_task_async( # AND a RecordBasedMetadataTaskProperties task_properties = RecordBasedMetadataTaskProperties( record_set_id=record_set.id, + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, ) # AND a CurationTask @@ -186,6 +188,15 @@ async def test_store_record_based_curation_task_async( stored_task.task_properties, RecordBasedMetadataTaskProperties ) assert stored_task.task_properties.record_set_id == record_set.id + # AND the authorization mode round-trips through the server as the enum + assert ( + stored_task.task_properties.suggested_authorization_mode + == AuthorizationMode.SESSION_OWNER + ) + assert isinstance( + stored_task.task_properties.suggested_authorization_mode, + AuthorizationMode, + ) assert stored_task.etag is not None assert stored_task.created_on is not None assert stored_task.created_by is not None @@ -259,6 +270,7 @@ async def test_get_curation_task_async( task_properties = FileBasedMetadataTaskProperties( upload_folder_id=folder.id, file_view_id=entity_view.id, + suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, ) original_task = await CurationTask( data_type=data_type, @@ -282,6 +294,10 @@ async def test_get_curation_task_async( ) assert retrieved_task.task_properties.upload_folder_id == folder.id assert retrieved_task.task_properties.file_view_id == entity_view.id + assert ( + retrieved_task.task_properties.suggested_authorization_mode + == AuthorizationMode.SOURCE_BENEFACTOR + ) assert retrieved_task.etag == original_task.etag assert retrieved_task.created_on == original_task.created_on assert retrieved_task.created_by == original_task.created_by @@ -735,6 +751,7 @@ async def test_create_grid_session_async( task_properties = FileBasedMetadataTaskProperties( upload_folder_id=folder.id, file_view_id=entity_view.id, + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, ) stored_task = await CurationTask( data_type=data_type, @@ -743,15 +760,22 @@ async def test_create_grid_session_async( task_properties=task_properties, ).store_async(synapse_client=syn) - # WHEN I create a grid session for the task asynchronously + # WHEN I create a grid session for the task asynchronously, owned by the + # current user + current_user_id = int(syn.getUserProfile()["ownerId"]) grid = await stored_task.create_grid_session_async( - timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=syn + owner_principal_id=current_user_id, + timeout=ASYNC_JOB_TIMEOUT_SEC, + synapse_client=syn, ) request.addfinalizer(lambda: grid.delete(synapse_client=syn)) - # THEN a Grid is returned with a populated session_id + # THEN a Grid is returned with a populated session_id owned by that user + # AND the grid carries the task's suggested authorization mode assert isinstance(grid, Grid) assert grid.session_id is not None + assert grid.owner_principal_id == current_user_id + assert grid.authorization_mode == AuthorizationMode.SESSION_OWNER # AND the curation task status now references the new grid session status = await stored_task.get_status_async(synapse_client=syn) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index 75b297333..f29dd630a 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -11,6 +11,7 @@ from synapseclient import Synapse from synapseclient.core.utils import make_bogus_data_file from synapseclient.models import ( + AuthorizationMode, EntityView, File, Folder, @@ -139,6 +140,26 @@ async def test_create_and_list_grid_sessions_async( assert our_session.started_by == created_grid.started_by assert our_session.source_entity_id == record_set_fixture.id + async def test_create_grid_session_with_authorization_mode_async( + self, record_set_fixture: RecordSet + ) -> None: + # GIVEN: A Grid instance with a record_set_id and an explicit authorization mode + grid = Grid( + record_set_id=record_set_fixture.id, + authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + + # WHEN: Creating a grid session + # (authorization_mode is serialized into the CreateGridRequest sent to Synapse) + created_grid = await grid.create_async( + timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn + ) + + # THEN: The server accepts the request and creates the session successfully + assert created_grid is grid + assert created_grid.session_id is not None + assert created_grid.source_entity_id == record_set_fixture.id + async def test_create_grid_session_and_reuse_session_async( self, record_set_fixture: RecordSet ) -> None: diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index df4f62dc2..f368cc026 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -110,7 +110,9 @@ def _get_grid_session_response(): "lastReplicaIdService": -5, "gridJsonSchema$Id": "my-schema-id", "sourceEntityId": SOURCE_ENTITY_ID, - "ownerPrincipalId": OWNER_PRINCIPAL_ID, + # The server returns ownerPrincipalId as a string; the client coerces to int. + "ownerPrincipalId": str(OWNER_PRINCIPAL_ID), + "authorizationMode": "SESSION_OWNER", } @@ -1386,7 +1388,12 @@ def test_fill_from_dict(self) -> None: assert grid.last_replica_id_service == -5 assert grid.grid_json_schema_id == "my-schema-id" assert grid.source_entity_id == SOURCE_ENTITY_ID + # AND the owner principal id is coerced from the response string to an int assert grid.owner_principal_id == OWNER_PRINCIPAL_ID + assert isinstance(grid.owner_principal_id, int) + # AND the authorization mode is coerced from the string to the enum + assert grid.authorization_mode == AuthorizationMode.SESSION_OWNER + assert isinstance(grid.authorization_mode, AuthorizationMode) async def test_create_async_with_record_set_id(self) -> None: # GIVEN a Grid with a record_set_id @@ -1406,11 +1413,13 @@ async def test_create_async_with_record_set_id(self) -> None: ): result = await grid.create_async(synapse_client=self.syn) - # THEN the grid should be populated with session data + # THEN the grid should be populated with session data, including the + # authorization mode coerced from the response string to the enum assert result.session_id == SESSION_ID assert result.started_by == STARTED_BY assert result.started_on == STARTED_ON assert result.source_entity_id == SOURCE_ENTITY_ID + assert result.authorization_mode == AuthorizationMode.SESSION_OWNER async def test_create_async_no_record_set_or_query_raises(self) -> None: # GIVEN a Grid with neither record_set_id nor initial_query From 427a7fbdc49e3754faea9c0deb459a3e5df07964 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 08:56:53 -0700 Subject: [PATCH 09/18] fix typing --- synapseclient/models/curation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 7e5e86f8b..2c461380a 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -2091,7 +2091,7 @@ class CreateGridRequest(EnumCoercionMixin, AsynchronousCommunicator): When a team ID is provided as the owner, all members of that team will have equal access to the grid. Note: If a team ID is provided, the creator of the grid must be a member of the team.""" - authorization_mode: Optional[AuthorizationMode] = None + authorization_mode: Optional[Union[AuthorizationMode, str]] = None """Controls access permissions and row visibility at session creation time. See AuthorizationMode. When omitted, the service defaults to SESSION_OWNER.""" @@ -3140,7 +3140,7 @@ class Grid(EnumCoercionMixin, GridSynchronousProtocol): """The principal ID (user or team) that will own the created grid session. When not provided, the principal ID of the caller is used.""" - authorization_mode: Optional[AuthorizationMode] = None + authorization_mode: Optional[Union[AuthorizationMode, str]] = None """Controls access permissions and row visibility at session creation time. See AuthorizationMode. When not provided, the service default (SESSION_OWNER) is used.""" From a6acc4aaf87c8104ac66b03d777c36a45d6734c2 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 08:57:25 -0700 Subject: [PATCH 10/18] added unit tests that check that CurationTask is getting passed the correct params --- .../extensions/unit_test_curator.py | 231 +++++++++--------- 1 file changed, 120 insertions(+), 111 deletions(-) diff --git a/tests/unit/synapseclient/extensions/unit_test_curator.py b/tests/unit/synapseclient/extensions/unit_test_curator.py index 5007e3844..7f9b6a96f 100644 --- a/tests/unit/synapseclient/extensions/unit_test_curator.py +++ b/tests/unit/synapseclient/extensions/unit_test_curator.py @@ -407,7 +407,7 @@ def test_create_file_based_metadata_task_schema_retrieval_error( ) @patch("synapseclient.extensions.curator.file_based_metadata_task.Folder") @patch("synapseclient.extensions.curator.file_based_metadata_task.CurationTask") - def test_create_file_based_metadata_task_with_assignee( + def test_create_file_based_metadata_task_forwards_all_params_to_curation_task( self, mock_curation_task_cls, mock_folder_cls, @@ -415,15 +415,34 @@ def test_create_file_based_metadata_task_with_assignee( mock_get_client, mock_get_project_id_from_entity_id, ): - """Test successful creation of file-based metadata task with assignee_principal_id.""" - # Test both string and int inputs - int should be converted to string + """Every parameter is forwarded to CurationTask. The assignee int is coerced + to a string, and suggested_authorization_mode supplied as a string is coerced + to the AuthorizationMode enum by FileBasedMetadataTaskProperties.""" + # (assignee_input, expected_assignee, auth_mode_input, expected_auth_mode) test_cases = [ - ("1234", "1234"), - (1234, "1234"), + ( + "1234", + "1234", + AuthorizationMode.SOURCE_BENEFACTOR, + AuthorizationMode.SOURCE_BENEFACTOR, + ), + ( + 1234, + "1234", + "SESSION_OWNER", + AuthorizationMode.SESSION_OWNER, + ), ] - for input_assignee, expected_assignee in test_cases: - with self.subTest(input_assignee=input_assignee): + for ( + input_assignee, + expected_assignee, + input_auth_mode, + expected_auth_mode, + ) in test_cases: + with self.subTest( + input_assignee=input_assignee, input_auth_mode=input_auth_mode + ): # Reset mocks for each subtest mock_curation_task_cls.reset_mock() mock_folder_cls.reset_mock() @@ -431,7 +450,8 @@ def test_create_file_based_metadata_task_with_assignee( mock_get_client.reset_mock() mock_get_project_id_from_entity_id.reset_mock() - # GIVEN a file-based metadata task with assignee_principal_id + # GIVEN a file-based metadata task with an assignee and an + # authorization mode mock_get_client.return_value = self.mock_syn mock_create_entity_view.return_value = "test_entity_view_id" mock_get_project_id_from_entity_id.return_value = self.project_id @@ -441,18 +461,13 @@ def test_create_file_based_metadata_task_with_assignee( mock_folder.get.return_value = mock_folder mock_folder.parent_id = "syn11111111" - mock_project = Mock() - mock_project.concreteType = "org.sagebionetworks.repo.model.Project" - mock_project.id = "syn22222222" - self.mock_syn.get.return_value = mock_project - mock_task = Mock() mock_task.task_id = "task123" mock_curation_task = Mock() mock_curation_task.store.return_value = mock_task mock_curation_task_cls.return_value = mock_curation_task - # WHEN I create the file-based metadata task with assignee_principal_id + # WHEN I create the file-based metadata task result = create_file_based_metadata_task( folder_id=self.folder_id, curation_task_name=self.curation_task_name, @@ -462,10 +477,12 @@ def test_create_file_based_metadata_task_with_assignee( schema_uri=self.schema_uri, enable_derived_annotations=True, assignee_principal_id=input_assignee, + suggested_authorization_mode=input_auth_mode, synapse_client=self.mock_syn, ) - # THEN the CurationTask should be called with assignee_principal_id as string + # THEN CurationTask is constructed with every parameter, the assignee + # coerced to a string and the authorization mode coerced to the enum mock_curation_task_cls.assert_called_once_with( data_type=self.curation_task_name, project_id=self.project_id, @@ -473,10 +490,11 @@ def test_create_file_based_metadata_task_with_assignee( assignee_principal_id=expected_assignee, task_properties=FileBasedMetadataTaskProperties( upload_folder_id=self.folder_id, - file_view_id=mock_create_entity_view.return_value, + file_view_id="test_entity_view_id", + suggested_authorization_mode=expected_auth_mode, ), ) - # AND the task should be created successfully + # AND the task is created successfully assert result == ("test_entity_view_id", "task123") mock_create_entity_view.assert_called_once_with( syn=self.mock_syn, @@ -485,62 +503,6 @@ def test_create_file_based_metadata_task_with_assignee( view_type_mask=ViewTypeMask.FILE, ) - @patch( - "synapseclient.extensions.curator.file_based_metadata_task.project_id_from_entity_id" - ) - @patch( - "synapseclient.extensions.curator.file_based_metadata_task.Synapse.get_client" - ) - @patch( - "synapseclient.extensions.curator.file_based_metadata_task.create_json_schema_entity_view" - ) - @patch("synapseclient.extensions.curator.file_based_metadata_task.Folder") - @patch("synapseclient.extensions.curator.file_based_metadata_task.CurationTask") - def test_create_file_based_metadata_task_with_authorization_mode( - self, - mock_curation_task_cls, - mock_folder_cls, - mock_create_entity_view, - mock_get_client, - mock_get_project_id_from_entity_id, - ): - """The authorization params are forwarded into FileBasedMetadataTaskProperties.""" - # GIVEN a file-based metadata task with authorization params - mock_get_client.return_value = self.mock_syn - mock_create_entity_view.return_value = "test_entity_view_id" - mock_get_project_id_from_entity_id.return_value = self.project_id - - mock_folder = Mock() - mock_folder_cls.return_value = mock_folder - mock_folder.get.return_value = mock_folder - mock_folder.parent_id = "syn11111111" - - mock_task = Mock() - mock_task.task_id = "task123" - mock_curation_task = Mock() - mock_curation_task.store.return_value = mock_task - mock_curation_task_cls.return_value = mock_curation_task - - # WHEN I create the task with an authorization mode - create_file_based_metadata_task( - folder_id=self.folder_id, - curation_task_name=self.curation_task_name, - instructions=self.instructions, - attach_wiki=False, - entity_view_name=self.entity_view_name, - schema_uri=self.schema_uri, - suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, - synapse_client=self.mock_syn, - ) - - # THEN the CurationTask is built with that mode on its task_properties - _, kwargs = mock_curation_task_cls.call_args - assert kwargs["task_properties"] == FileBasedMetadataTaskProperties( - upload_folder_id=self.folder_id, - file_view_id="test_entity_view_id", - suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, - ) - class TestCreateRecordBasedMetadataTask(unittest.TestCase): """Test cases for create_record_based_metadata_task function.""" @@ -679,7 +641,7 @@ def test_create_record_based_metadata_task_success( @patch("synapseclient.extensions.curator.record_based_metadata_task.CurationTask") @patch("synapseclient.extensions.curator.record_based_metadata_task.Grid") @patch("builtins.open") - def test_create_record_based_metadata_task_with_authorization_mode( + def test_create_record_based_metadata_task_forwards_all_params_to_curation_task( self, mock_open, mock_grid_cls, @@ -690,49 +652,96 @@ def test_create_record_based_metadata_task_with_authorization_mode( mock_get_client, mock_get_project_id_from_entity_id, ): - """The authorization params are forwarded into RecordBasedMetadataTaskProperties.""" - # GIVEN a record-based metadata task with authorization params - mock_get_client.return_value = self.mock_syn - mock_get_project_id_from_entity_id.return_value = self.project_id + """Every parameter is forwarded to CurationTask. The assignee int is coerced + to a string, and suggested_authorization_mode supplied as a string is coerced + to the AuthorizationMode enum by RecordBasedMetadataTaskProperties.""" + # (assignee_input, expected_assignee, auth_mode_input, expected_auth_mode) + test_cases = [ + ( + "1234", + "1234", + AuthorizationMode.SOURCE_BENEFACTOR, + AuthorizationMode.SOURCE_BENEFACTOR, + ), + ( + 1234, + "1234", + "SESSION_OWNER", + AuthorizationMode.SESSION_OWNER, + ), + ] + + for ( + input_assignee, + expected_assignee, + input_auth_mode, + expected_auth_mode, + ) in test_cases: + with self.subTest( + input_assignee=input_assignee, input_auth_mode=input_auth_mode + ): + # Reset mocks for each subtest + for mock_obj in ( + mock_grid_cls, + mock_curation_task_cls, + mock_record_set_cls, + mock_temp_file, + mock_extract_schema, + mock_get_client, + mock_get_project_id_from_entity_id, + ): + mock_obj.reset_mock() + + # GIVEN a record-based metadata task with an assignee and an + # authorization mode + mock_get_client.return_value = self.mock_syn + mock_get_project_id_from_entity_id.return_value = self.project_id - mock_extract_schema.return_value = pd.DataFrame(columns=["specimenID"]) + mock_extract_schema.return_value = pd.DataFrame(columns=["specimenID"]) - mock_temp = Mock() - mock_temp.name = "/tmp/test.csv" - mock_temp_file.return_value = mock_temp + mock_temp = Mock() + mock_temp.name = "/tmp/test.csv" + mock_temp_file.return_value = mock_temp - mock_record_set = Mock() - mock_record_set.id = "syn87654321" - mock_record_set_instance = Mock() - mock_record_set_instance.store.return_value = mock_record_set - mock_record_set_cls.return_value = mock_record_set_instance + mock_record_set = Mock() + mock_record_set.id = "syn87654321" + mock_record_set_instance = Mock() + mock_record_set_instance.store.return_value = mock_record_set + mock_record_set_cls.return_value = mock_record_set_instance - mock_task = Mock() - mock_task.task_id = "task123" - mock_curation_task = Mock() - mock_curation_task.store.return_value = mock_task - mock_curation_task_cls.return_value = mock_curation_task + mock_task = Mock() + mock_task.task_id = "task123" + mock_curation_task = Mock() + mock_curation_task.store.return_value = mock_task + mock_curation_task_cls.return_value = mock_curation_task - # WHEN I create the task with an authorization mode - create_record_based_metadata_task( - folder_id=self.folder_id, - record_set_name=self.record_set_name, - record_set_description=self.record_set_description, - curation_task_name=self.curation_task_name, - upsert_keys=self.upsert_keys, - instructions=self.instructions, - schema_uri=self.schema_uri, - suggested_authorization_mode="SESSION_OWNER", - create_grid=False, - synapse_client=self.mock_syn, - ) + # WHEN I create the record-based metadata task + create_record_based_metadata_task( + folder_id=self.folder_id, + record_set_name=self.record_set_name, + record_set_description=self.record_set_description, + curation_task_name=self.curation_task_name, + upsert_keys=self.upsert_keys, + instructions=self.instructions, + schema_uri=self.schema_uri, + assignee_principal_id=input_assignee, + suggested_authorization_mode=input_auth_mode, + create_grid=False, + synapse_client=self.mock_syn, + ) - # THEN the CurationTask is built with that mode on its task_properties - _, kwargs = mock_curation_task_cls.call_args - assert kwargs["task_properties"] == RecordBasedMetadataTaskProperties( - record_set_id="syn87654321", - suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, - ) + # THEN CurationTask is constructed with every parameter, the assignee + # coerced to a string and the authorization mode coerced to the enum + mock_curation_task_cls.assert_called_once_with( + data_type=self.curation_task_name, + project_id=self.project_id, + instructions=self.instructions, + assignee_principal_id=expected_assignee, + task_properties=RecordBasedMetadataTaskProperties( + record_set_id="syn87654321", + suggested_authorization_mode=expected_auth_mode, + ), + ) @patch( "synapseclient.extensions.curator.record_based_metadata_task.project_id_from_entity_id" From 3b3e8861c7c4e618466e4a253c2a84ae705c5c07 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 09:14:22 -0700 Subject: [PATCH 11/18] added unit tests --- .../models/async/unit_test_curation_async.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index f368cc026..1e5859964 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -1421,6 +1421,30 @@ async def test_create_async_with_record_set_id(self) -> None: assert result.source_entity_id == SOURCE_ENTITY_ID assert result.authorization_mode == AuthorizationMode.SESSION_OWNER + async def test_create_async_forwards_authorization_mode_to_request(self) -> None: + # GIVEN a Grid with a record_set_id and an explicit authorization mode + grid = Grid( + record_set_id=RECORD_SET_ID, + authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + + # WHEN I call create_async (patching the CreateGridRequest the Grid builds) + with patch( + "synapseclient.models.curation.CreateGridRequest" + ) as mock_request_cls: + mock_request = mock_request_cls.return_value + mock_request.send_job_and_wait_async = AsyncMock(return_value=mock_request) + await grid.create_async(synapse_client=self.syn) + + # THEN the request is constructed once with the grid's authorization mode + # forwarded alongside the other session parameters + mock_request_cls.assert_called_once_with( + record_set_id=RECORD_SET_ID, + initial_query=None, + owner_principal_id=None, + authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + ) + async def test_create_async_no_record_set_or_query_raises(self) -> None: # GIVEN a Grid with neither record_set_id nor initial_query grid = Grid() @@ -1801,6 +1825,12 @@ def test_fill_grid_session_from_response(self) -> None: assert grid.started_by == STARTED_BY assert grid.etag == GRID_ETAG assert grid.source_entity_id == SOURCE_ENTITY_ID + # AND the owner principal id is coerced from the response string to an int + assert grid.owner_principal_id == OWNER_PRINCIPAL_ID + assert isinstance(grid.owner_principal_id, int) + # AND the authorization mode is coerced from the response string to the enum + assert grid.authorization_mode == AuthorizationMode.SESSION_OWNER + assert isinstance(grid.authorization_mode, AuthorizationMode) def test_to_synapse_request_with_record_set_id(self) -> None: # GIVEN a CreateGridRequest with a record_set_id @@ -1813,6 +1843,25 @@ def test_to_synapse_request_with_record_set_id(self) -> None: assert "concreteType" in result assert result["recordSetId"] == RECORD_SET_ID assert "initialQuery" not in result + # AND the absent authorization mode is dropped by delete_none_keys + assert "authorizationMode" not in result + + def test_to_synapse_request_with_authorization_mode(self) -> None: + # GIVEN a CreateGridRequest with the authorization mode supplied as a string + request = CreateGridRequest( + record_set_id=RECORD_SET_ID, + authorization_mode="SOURCE_BENEFACTOR", + ) + + # THEN the string is coerced to the enum on assignment by EnumCoercionMixin + assert request.authorization_mode == AuthorizationMode.SOURCE_BENEFACTOR + assert isinstance(request.authorization_mode, AuthorizationMode) + + # WHEN I convert it to a synapse request + result = request.to_synapse_request() + + # THEN the enum value is serialized back to its string form + assert result["authorizationMode"] == "SOURCE_BENEFACTOR" class TestUploadToTablePreviewRequest: From b3f555d94917e9550697314eab6a22c00f67becd Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 09:20:19 -0700 Subject: [PATCH 12/18] fix wording in doc --- docs/guides/extensions/curator/metadata_curation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/extensions/curator/metadata_curation.md b/docs/guides/extensions/curator/metadata_curation.md index f84407aad..fd2dabdb1 100644 --- a/docs/guides/extensions/curator/metadata_curation.md +++ b/docs/guides/extensions/curator/metadata_curation.md @@ -135,7 +135,7 @@ print(f"Created CurationTask: {task_id}") Both `create_record_based_metadata_task` and `create_file_based_metadata_task` accept an optional `suggested_authorization_mode` that tells clients how to scope access when a grid session is created for the task: -- `AuthorizationMode.SESSION_OWNER` (the default behavior when the mode is omitted) limits access to the session owner and their team. Use it when curation should be restricted to a specific user or team. +- `AuthorizationMode.SESSION_OWNER` (the server default applied when the mode is omitted) limits access to the session owner and their team. Use it when curation should be restricted to a specific user or team. - `AuthorizationMode.SOURCE_BENEFACTOR` extends access to anyone with `EDIT` rights on the source entity. Use it when curation should be open to all editors of the source. ```python From 58445bb7e1c9e7c2435c31df39a661c5ceae95ae Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 09:35:03 -0700 Subject: [PATCH 13/18] cleaned up example --- docs/guides/extensions/curator/metadata_curation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guides/extensions/curator/metadata_curation.md b/docs/guides/extensions/curator/metadata_curation.md index fd2dabdb1..eb04f6a06 100644 --- a/docs/guides/extensions/curator/metadata_curation.md +++ b/docs/guides/extensions/curator/metadata_curation.md @@ -135,8 +135,8 @@ print(f"Created CurationTask: {task_id}") Both `create_record_based_metadata_task` and `create_file_based_metadata_task` accept an optional `suggested_authorization_mode` that tells clients how to scope access when a grid session is created for the task: -- `AuthorizationMode.SESSION_OWNER` (the server default applied when the mode is omitted) limits access to the session owner and their team. Use it when curation should be restricted to a specific user or team. -- `AuthorizationMode.SOURCE_BENEFACTOR` extends access to anyone with `EDIT` rights on the source entity. Use it when curation should be open to all editors of the source. +- `SESSION_OWNER` (the server default applied when the mode is omitted) limits access to the session owner and their team. Use it when curation should be restricted to a specific user or team. +- `SOURCE_BENEFACTOR` extends access to anyone with `EDIT` rights on the source entity. Use it when curation should be open to all editors of the source. ```python from synapseclient.models import AuthorizationMode @@ -148,7 +148,7 @@ entity_view_id, task_id = create_file_based_metadata_task( instructions="Annotate each file with metadata according to the schema requirements.", entity_view_name="Animal Study Files View", schema_uri=schema_uri, - suggested_authorization_mode=AuthorizationMode.SOURCE_BENEFACTOR, + suggested_authorization_mode="SOURCE_BENEFACTOR", ) ``` From f0a160269e4e0428ab4a36bee20965a886a1aaeb Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 09:39:41 -0700 Subject: [PATCH 14/18] improved docstrings --- .../curator/file_based_metadata_task.py | 21 ++++++++++++------- .../curator/record_based_metadata_task.py | 21 ++++++++++++------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/synapseclient/extensions/curator/file_based_metadata_task.py b/synapseclient/extensions/curator/file_based_metadata_task.py index ea5bafde1..287685dd1 100644 --- a/synapseclient/extensions/curator/file_based_metadata_task.py +++ b/synapseclient/extensions/curator/file_based_metadata_task.py @@ -380,14 +380,19 @@ def create_file_based_metadata_task( ViewTypeMask.FILE. Additional types can be added using bitwise OR (e.g., ViewTypeMask.FILE | ViewTypeMask.DOCKER). Accepts either a ViewTypeMask enum member or its raw integer value. - suggested_authorization_mode: The authorization mode a client should use when - creating a linked grid session for this task. When omitted, clients follow - legacy behavior: find or create a personal, unlinked grid session. - SESSION_OWNER limits access to the session owner and their team; - SOURCE_BENEFACTOR extends access to anyone with EDIT rights on the source - entity. Note that changing suggested_authorization_mode after the task has - been created causes the server to clear activeSessionId on the task status, - so a new grid session must be created before curation can continue. + suggested_authorization_mode: Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. diff --git a/synapseclient/extensions/curator/record_based_metadata_task.py b/synapseclient/extensions/curator/record_based_metadata_task.py index 1e05e8d46..beeeae526 100644 --- a/synapseclient/extensions/curator/record_based_metadata_task.py +++ b/synapseclient/extensions/curator/record_based_metadata_task.py @@ -185,14 +185,19 @@ def create_record_based_metadata_task( (default), the task will be unassigned. For metadata tasks, this determines the owner of the grid session. Team members can all join grid sessions owned by their team, while user-owned grid sessions are restricted to that user only. - suggested_authorization_mode: The authorization mode a client should use when - creating a linked grid session for this task. When omitted, clients follow - legacy behavior: find or create a personal, unlinked grid session. - SESSION_OWNER limits access to the session owner and their team; - SOURCE_BENEFACTOR extends access to anyone with EDIT rights on the source - entity. Note that changing suggested_authorization_mode after the task has - been created causes the server to clear activeSessionId on the task status, - so a new grid session must be created before curation can continue. + suggested_authorization_mode: Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. From 35719337a278eee1176805440ef04706405c6e3e Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 09:44:29 -0700 Subject: [PATCH 15/18] improved docstrings --- synapseclient/models/curation.py | 42 ++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 2c461380a..4412310f6 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -136,14 +136,19 @@ class FileBasedMetadataTaskProperties(EnumCoercionMixin): """The synId of the FileView that shows all data of this type""" suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None - """The authorization mode a client should use when creating a linked grid session for - this task. SESSION_OWNER limits access to the session owner and their team; use it - when curation should be scoped to a specific user or team. SOURCE_BENEFACTOR extends - access to anyone with EDIT rights on the source entity; use it when curation should - be open to all editors of the source. When omitted, clients follow legacy behavior: - find or create a personal, unlinked grid session. When this field changes, the server - automatically clears activeSessionId from the task status. Accepts either an - AuthorizationMode enum value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" + """Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue.""" collaborator_principal_ids: Optional[list[str]] = None """Not actively used at this time. @@ -213,14 +218,19 @@ class RecordBasedMetadataTaskProperties(EnumCoercionMixin): """The synId of the RecordSet that will contain all record-based metadata""" suggested_authorization_mode: Optional[Union[AuthorizationMode, str]] = None - """The authorization mode a client should use when creating a linked grid session for - this task. SESSION_OWNER limits access to the session owner and their team; use it - when curation should be scoped to a specific user or team. SOURCE_BENEFACTOR extends - access to anyone with EDIT rights on the source entity; use it when curation should - be open to all editors of the source. When omitted, clients follow legacy behavior: - find or create a personal, unlinked grid session. When this field changes, the server - automatically clears activeSessionId from the task status. Accepts either an - AuthorizationMode enum value or its string equivalent (e.g., "SOURCE_BENEFACTOR").""" + """Recommends who is allowed to access the curation + grid session that a client opens for this task. The value is stored on the + task as a suggestion; the client applies it when it creates a new session. + Choose from: + - SESSION_OWNER: only the person or team who owns the session can access it. + - SOURCE_BENEFACTOR: anyone with EDIT permission on the + data being curated can access the session. This lets editors collaborate + in the same session without being added to a shared ownership team. + When omitted (None, the default), no recommendation is stored and clients + fall back to their usual behavior of finding or creating a private session + for the current user. Changing this value after the task already exists + resets the task's active session, so a new grid session must be opened + before curation can continue.""" collaborator_principal_ids: Optional[list[str]] = None """Not actively used at this time. From f66d27d02f801606d1581603cc5b6b78bcdf4b18 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 10:09:11 -0700 Subject: [PATCH 16/18] clean up tests --- .../synapseclient/models/async/test_curation_async.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/synapseclient/models/async/test_curation_async.py b/tests/integration/synapseclient/models/async/test_curation_async.py index 12948dded..2599b12f7 100644 --- a/tests/integration/synapseclient/models/async/test_curation_async.py +++ b/tests/integration/synapseclient/models/async/test_curation_async.py @@ -131,6 +131,7 @@ async def test_store_file_based_curation_task_async( task_properties = FileBasedMetadataTaskProperties( upload_folder_id=folder.id, file_view_id=entity_view.id, + suggested_authorization_mode=AuthorizationMode.SESSION_OWNER, ) # AND a CurationTask @@ -153,6 +154,10 @@ async def test_store_file_based_curation_task_async( assert isinstance(stored_task.task_properties, FileBasedMetadataTaskProperties) assert stored_task.task_properties.upload_folder_id == folder.id assert stored_task.task_properties.file_view_id == entity_view.id + assert ( + stored_task.task_properties.suggested_authorization_mode + == AuthorizationMode.SESSION_OWNER + ) assert stored_task.etag is not None assert stored_task.created_on is not None assert stored_task.created_by is not None @@ -188,15 +193,10 @@ async def test_store_record_based_curation_task_async( stored_task.task_properties, RecordBasedMetadataTaskProperties ) assert stored_task.task_properties.record_set_id == record_set.id - # AND the authorization mode round-trips through the server as the enum assert ( stored_task.task_properties.suggested_authorization_mode == AuthorizationMode.SESSION_OWNER ) - assert isinstance( - stored_task.task_properties.suggested_authorization_mode, - AuthorizationMode, - ) assert stored_task.etag is not None assert stored_task.created_on is not None assert stored_task.created_by is not None From 15492447b6adf333a6733532e8077c70cfc77b2c Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 10:17:14 -0700 Subject: [PATCH 17/18] add assertion to test --- tests/integration/synapseclient/models/async/test_grid_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index f29dd630a..6e0eb2a8a 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -150,7 +150,6 @@ async def test_create_grid_session_with_authorization_mode_async( ) # WHEN: Creating a grid session - # (authorization_mode is serialized into the CreateGridRequest sent to Synapse) created_grid = await grid.create_async( timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn ) @@ -159,6 +158,7 @@ async def test_create_grid_session_with_authorization_mode_async( assert created_grid is grid assert created_grid.session_id is not None assert created_grid.source_entity_id == record_set_fixture.id + assert created_grid.authorization_mode == AuthorizationMode.SOURCE_BENEFACTOR async def test_create_grid_session_and_reuse_session_async( self, record_set_fixture: RecordSet From 85809e4eadbccdc6ada763bc486be63bfc31a3bc Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 17 Jun 2026 10:24:04 -0700 Subject: [PATCH 18/18] added finalizer --- .../synapseclient/models/async/test_grid_async.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index 6e0eb2a8a..cba114053 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -9,6 +9,7 @@ import pytest from synapseclient import Synapse +from synapseclient.core.async_utils import wrap_async_to_sync from synapseclient.core.utils import make_bogus_data_file from synapseclient.models import ( AuthorizationMode, @@ -141,7 +142,7 @@ async def test_create_and_list_grid_sessions_async( assert our_session.source_entity_id == record_set_fixture.id async def test_create_grid_session_with_authorization_mode_async( - self, record_set_fixture: RecordSet + self, request: pytest.FixtureRequest, record_set_fixture: RecordSet ) -> None: # GIVEN: A Grid instance with a record_set_id and an explicit authorization mode grid = Grid( @@ -154,6 +155,13 @@ async def test_create_grid_session_with_authorization_mode_async( timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn ) + # AND: The grid session is scheduled for cleanup + request.addfinalizer( + lambda: wrap_async_to_sync( + created_grid.delete_async(synapse_client=self.syn) + ) + ) + # THEN: The server accepts the request and creates the session successfully assert created_grid is grid assert created_grid.session_id is not None