From 76fd0ad7ba689279f8fffa6997c1a650793b20e7 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 23 Feb 2026 15:44:13 -0500 Subject: [PATCH 1/5] gwy: user is now added to the group it creates --- gateway/.envs/example/django.prod-example.env | 2 +- gateway/sds_gateway/users/tests/test_group_sharing.py | 3 ++- gateway/sds_gateway/users/views.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/gateway/.envs/example/django.prod-example.env b/gateway/.envs/example/django.prod-example.env index f70291af..3f5a7c05 100644 --- a/gateway/.envs/example/django.prod-example.env +++ b/gateway/.envs/example/django.prod-example.env @@ -127,7 +127,7 @@ SDS_SHORT_INSTITUTION_NAME=SU SDS_PROGRAMMATIC_SITE_NAME=streeling # Fully qualified domain name for SDK and (future) federation communications; unique -# across all deployments (e.g. `merrimack.haystack.nd.edu`, `sds.crc.nd.edu`). +# across all deployments (e.g. `merrimack.haystack.mit.edu`, `sds.crc.nd.edu`). SDS_SITE_FQDN=streeling.example.com # Brand image path for navbar and page backgrounds diff --git a/gateway/sds_gateway/users/tests/test_group_sharing.py b/gateway/sds_gateway/users/tests/test_group_sharing.py index 66343592..331ab599 100644 --- a/gateway/sds_gateway/users/tests/test_group_sharing.py +++ b/gateway/sds_gateway/users/tests/test_group_sharing.py @@ -278,9 +278,10 @@ def test_create_share_group(self, client: Client, owner: User) -> None: result = response.json() assert result["success"] is True - # Verify group was created + # Verify group was created and owner is a member group = ShareGroup.objects.get(name="New Test Group", owner=owner) assert group.is_deleted is False + assert owner in group.members.all() def test_add_member_to_group( self, diff --git a/gateway/sds_gateway/users/views.py b/gateway/sds_gateway/users/views.py index e5c7e8d6..10079d3b 100644 --- a/gateway/sds_gateway/users/views.py +++ b/gateway/sds_gateway/users/views.py @@ -3628,6 +3628,7 @@ def _create_share_group(self, request: HttpRequest) -> JsonResponse: try: share_group = ShareGroup.objects.create(name=name, owner=request.user) + share_group.members.add(request.user) return JsonResponse( { @@ -3637,7 +3638,7 @@ def _create_share_group(self, request: HttpRequest) -> JsonResponse: "uuid": str(share_group.uuid), "name": share_group.name, "created_at": share_group.created_at.isoformat(), - "member_count": 0, + "member_count": 1, }, } ) From aabf89261224ceb6369a9736582bb0a89ff11b67 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 23 Feb 2026 15:46:41 -0500 Subject: [PATCH 2/5] gwy: migration: retroactively adds group owners as members --- .../0020_add_group_owner_as_member.py | 32 +++++++++++++++++++ .../api_methods/migrations/max_migration.txt | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py diff --git a/gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py b/gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py new file mode 100644 index 00000000..53308b2c --- /dev/null +++ b/gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py @@ -0,0 +1,32 @@ +"""Data migration: add each ShareGroup owner as a member of their own group.""" + +from django.db import migrations + + +def add_owners_as_members(apps, schema_editor): + ShareGroup = apps.get_model("api_methods", "ShareGroup") + for group in ShareGroup.objects.filter(is_deleted=False).select_related("owner"): + group.members.add(group.owner) + + +def remove_owners_as_members(apps, schema_editor): + """Reverse: remove owners who are members solely because of this migration. + + This is a best-effort reverse — it removes the owner from members for + every non-deleted group. Owners who were added manually before this + migration was applied will also be removed, but that is an acceptable + trade-off for a reversible migration. + """ + ShareGroup = apps.get_model("api_methods", "ShareGroup") + for group in ShareGroup.objects.filter(is_deleted=False).select_related("owner"): + group.members.remove(group.owner) + + +class Migration(migrations.Migration): + dependencies = [ + ("api_methods", "0019_capture_datasets_file_captures_file_datasets_and_more"), + ] + + operations = [ + migrations.RunPython(add_owners_as_members, remove_owners_as_members), + ] diff --git a/gateway/sds_gateway/api_methods/migrations/max_migration.txt b/gateway/sds_gateway/api_methods/migrations/max_migration.txt index 917b8df3..f612a4dc 100644 --- a/gateway/sds_gateway/api_methods/migrations/max_migration.txt +++ b/gateway/sds_gateway/api_methods/migrations/max_migration.txt @@ -1 +1 @@ -0019_capture_datasets_file_captures_file_datasets_and_more +0020_add_group_owner_as_member From 683b49f4eaee055f49848b4347090f4fe1e6bf25 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 23 Feb 2026 15:54:56 -0500 Subject: [PATCH 3/5] gwy: prevents removing users from groups in some situations --- .../users/tests/test_group_sharing.py | 50 +++++++++++++++++++ gateway/sds_gateway/users/views.py | 24 ++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/gateway/sds_gateway/users/tests/test_group_sharing.py b/gateway/sds_gateway/users/tests/test_group_sharing.py index 331ab599..22686a17 100644 --- a/gateway/sds_gateway/users/tests/test_group_sharing.py +++ b/gateway/sds_gateway/users/tests/test_group_sharing.py @@ -361,6 +361,56 @@ def test_remove_member_from_group( assert permission.is_enabled is False assert share_group not in permission.share_groups.all() + def test_cannot_remove_owner_from_group( + self, + client: Client, + owner: User, + group_member: User, + ) -> None: + """Test that the group owner cannot be removed from the group.""" + group = ShareGroup.objects.create(name="Owner Test Group", owner=owner) + group.members.add(owner, group_member) + + client.force_login(owner) + url = reverse("users:share_group_list") + data = { + "action": "remove_members", + "group_uuid": str(group.uuid), + "user_emails": owner.email, + } + + response = client.post(url, data) + assert response.status_code == status.HTTP_200_OK + result = response.json() + # The removal is blocked; the owner should still be a member + assert owner in group.members.all() + assert result["errors"] + + def test_cannot_remove_last_member_from_group( + self, + client: Client, + owner: User, + group_member: User, + share_group: ShareGroup, + ) -> None: + """Test that the last member of a group cannot be removed.""" + # share_group fixture has only group_member as a member (owner not added) + assert share_group.members.count() == 1 + + client.force_login(owner) + url = reverse("users:share_group_list") + data = { + "action": "remove_members", + "group_uuid": str(share_group.uuid), + "user_emails": group_member.email, + } + + response = client.post(url, data) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert group_member in share_group.members.all() + assert result["errors"] + def test_delete_share_group( self, client: Client, diff --git a/gateway/sds_gateway/users/views.py b/gateway/sds_gateway/users/views.py index 10079d3b..df597a4b 100644 --- a/gateway/sds_gateway/users/views.py +++ b/gateway/sds_gateway/users/views.py @@ -3809,15 +3809,25 @@ def _process_user_removal( try: user = User.objects.get(email=email) - if share_group.members.filter(id=user.id).exists(): - share_group.members.remove(user) + if not share_group.members.filter(id=user.id).exists(): + errors.append(f"User {email} is not a member of this group") + continue + + if user == share_group.owner: + errors.append( + f"User {email} is the owner of this group and cannot be removed" + ) + continue - # Update share permissions for this user - self._update_user_share_permissions_on_removal(user, share_group) + if share_group.members.count() <= 1: + errors.append( + f"Cannot remove {email}: they are the last member of this group" + ) + continue - removed_users.append(email) - else: - errors.append(f"User {email} is not a member of this group") + share_group.members.remove(user) + self._update_user_share_permissions_on_removal(user, share_group) + removed_users.append(email) except User.DoesNotExist: errors.append(f"User with email {email} not found") From 86a812bd524595abc3ec98efe493e1fb8498f0c2 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 23 Feb 2026 17:44:50 -0500 Subject: [PATCH 4/5] gwy: new UserSharePermission.is_individual_share flag to track how an item was shared --- ...r_as_member_and_individual_share_field.py} | 19 ++++++++++++++----- .../api_methods/migrations/max_migration.txt | 2 +- gateway/sds_gateway/api_methods/models.py | 14 +++++++------- gateway/sds_gateway/users/utils.py | 3 +++ gateway/sds_gateway/users/views.py | 15 +++++++-------- 5 files changed, 32 insertions(+), 21 deletions(-) rename gateway/sds_gateway/api_methods/migrations/{0020_add_group_owner_as_member.py => 0020_group_owner_as_member_and_individual_share_field.py} (55%) diff --git a/gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py b/gateway/sds_gateway/api_methods/migrations/0020_group_owner_as_member_and_individual_share_field.py similarity index 55% rename from gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py rename to gateway/sds_gateway/api_methods/migrations/0020_group_owner_as_member_and_individual_share_field.py index 53308b2c..c6221a09 100644 --- a/gateway/sds_gateway/api_methods/migrations/0020_add_group_owner_as_member.py +++ b/gateway/sds_gateway/api_methods/migrations/0020_group_owner_as_member_and_individual_share_field.py @@ -1,6 +1,12 @@ -"""Data migration: add each ShareGroup owner as a member of their own group.""" +"""Add each ShareGroup owner as a member of their own group; add is_individual_share to UserSharePermission. + +Existing UserSharePermission rows default to True (individual share), preserving all +current access. Permissions created purely through a group share are written with +False so that revoking the group also revokes the permission. +""" from django.db import migrations +from django.db import models def add_owners_as_members(apps, schema_editor): @@ -12,10 +18,8 @@ def add_owners_as_members(apps, schema_editor): def remove_owners_as_members(apps, schema_editor): """Reverse: remove owners who are members solely because of this migration. - This is a best-effort reverse — it removes the owner from members for - every non-deleted group. Owners who were added manually before this - migration was applied will also be removed, but that is an acceptable - trade-off for a reversible migration. + Best-effort — also removes owners added manually before this migration, which + is an acceptable trade-off for a reversible migration. """ ShareGroup = apps.get_model("api_methods", "ShareGroup") for group in ShareGroup.objects.filter(is_deleted=False).select_related("owner"): @@ -29,4 +33,9 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(add_owners_as_members, remove_owners_as_members), + migrations.AddField( + model_name="usersharepermission", + name="is_individual_share", + field=models.BooleanField(default=True), + ), ] diff --git a/gateway/sds_gateway/api_methods/migrations/max_migration.txt b/gateway/sds_gateway/api_methods/migrations/max_migration.txt index f612a4dc..5fc8ebc3 100644 --- a/gateway/sds_gateway/api_methods/migrations/max_migration.txt +++ b/gateway/sds_gateway/api_methods/migrations/max_migration.txt @@ -1 +1 @@ -0020_add_group_owner_as_member +0020_group_owner_as_member_and_individual_share_field diff --git a/gateway/sds_gateway/api_methods/models.py b/gateway/sds_gateway/api_methods/models.py index c987549c..6f81ae1f 100644 --- a/gateway/sds_gateway/api_methods/models.py +++ b/gateway/sds_gateway/api_methods/models.py @@ -920,6 +920,11 @@ class UserSharePermission(BaseModel): # Whether this share permission is currently active is_enabled = models.BooleanField(default=True) + # Whether this permission was explicitly granted to the user individually, + # as opposed to being created solely via a group share. When True, revocation + # of every group still keeps the permission active. + is_individual_share = models.BooleanField(default=True) + class Meta: unique_together = ["owner", "shared_with", "item_type", "item_uuid"] indexes = [ @@ -965,13 +970,8 @@ def has_access_through_groups(self) -> bool: return self.share_groups.filter(is_deleted=False).exists() def update_enabled_status(self) -> None: - """Update enabled status based on group access.""" - if self.share_groups.exists(): - # User has access through groups, keep enabled - self.is_enabled = True - else: - # No group access, disable - self.is_enabled = False + """Update enabled status based on individual and group access.""" + self.is_enabled = self.is_individual_share or self.share_groups.exists() self.save() @classmethod diff --git a/gateway/sds_gateway/users/utils.py b/gateway/sds_gateway/users/utils.py index a4083217..74b220c2 100644 --- a/gateway/sds_gateway/users/utils.py +++ b/gateway/sds_gateway/users/utils.py @@ -149,6 +149,7 @@ def update_or_create_user_group_share_permissions( message=message, permission_level=permission_level, is_enabled=True, + is_individual_share=False, ) permission.share_groups.add(group) else: @@ -156,6 +157,8 @@ def update_or_create_user_group_share_permissions( existing_individual.message = message existing_individual.permission_level = permission_level existing_individual.share_groups.add(group) + # is_individual_share is intentionally NOT changed here: if the user was + # already individually shared, that status must be preserved. existing_individual.save() diff --git a/gateway/sds_gateway/users/views.py b/gateway/sds_gateway/users/views.py index df597a4b..92d222f5 100644 --- a/gateway/sds_gateway/users/views.py +++ b/gateway/sds_gateway/users/views.py @@ -526,13 +526,17 @@ def _add_individual_user_to_item( ) if existing_permission: - if existing_permission.is_enabled: + if ( + existing_permission.is_enabled + and existing_permission.is_individual_share + ): return ( None, f"{item_type.capitalize()} is already shared with {email}", ) - # Re-enable the existing disabled permission and update permission level + # Re-enable and mark as explicitly individually shared existing_permission.is_enabled = True + existing_permission.is_individual_share = True existing_permission.message = message existing_permission.permission_level = permission_level existing_permission.save() @@ -547,6 +551,7 @@ def _add_individual_user_to_item( message=message, permission_level=permission_level, is_enabled=True, + is_individual_share=True, ) except User.DoesNotExist: return None, f"User with email {email} not found or not approved" @@ -3819,12 +3824,6 @@ def _process_user_removal( ) continue - if share_group.members.count() <= 1: - errors.append( - f"Cannot remove {email}: they are the last member of this group" - ) - continue - share_group.members.remove(user) self._update_user_share_permissions_on_removal(user, share_group) removed_users.append(email) From 407bdef2cc374ad73e88f844df281bdb88928f26 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 23 Feb 2026 17:49:47 -0500 Subject: [PATCH 5/5] gwy: new scenario test_individual_share_preserved_after_group_revocation --- .../tests/test_permission_updates.py | 79 +++++++++++++++++++ .../users/tests/test_group_sharing.py | 38 ++++----- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/gateway/sds_gateway/api_methods/tests/test_permission_updates.py b/gateway/sds_gateway/api_methods/tests/test_permission_updates.py index 05b94472..dbf8bf6d 100644 --- a/gateway/sds_gateway/api_methods/tests/test_permission_updates.py +++ b/gateway/sds_gateway/api_methods/tests/test_permission_updates.py @@ -238,6 +238,85 @@ def test_update_permission_missing_user(self): data = response.json() assert "not found" in data["error"] + def test_individual_share_preserved_after_group_revocation(self): + """ + Individual shares must survive a group-access revocation. + + Scenario: + 1. The owner (U2) grants U1 and U2 personal (non-group) access to X. + 2. The owner also shares X with group G (which contains U1 and U2). + Both users' existing permissions gain G in share_groups. + 3. The owner revokes group G's access to X. + 4. U1 and U2 must still have access through their individual shares. + 5. The owner must still have access via dataset ownership. + """ + # Step 1 - individual shares (no groups yet) + perm_user1 = UserSharePermission.objects.create( + owner=self.owner, + shared_with=self.user1, + item_type=ItemType.DATASET, + item_uuid=self.dataset.uuid, + permission_level=PermissionLevel.VIEWER, + is_enabled=True, + ) + perm_user2 = UserSharePermission.objects.create( + owner=self.owner, + shared_with=self.user2, + item_type=ItemType.DATASET, + item_uuid=self.dataset.uuid, + permission_level=PermissionLevel.VIEWER, + is_enabled=True, + ) + assert perm_user1.share_groups.count() == 0 + assert perm_user2.share_groups.count() == 0 + + # Step 2 - share X with group G (self.group has user1 and user2 as members) + self.client.force_login(self.owner) + add_response = self.client.post( + reverse( + "users:share_item", + kwargs={"item_type": "dataset", "item_uuid": self.dataset.uuid}, + ), + data={"user-search": f"group:{self.group.uuid}"}, + ) + assert add_response.status_code == status.HTTP_200_OK + + perm_user1.refresh_from_db() + perm_user2.refresh_from_db() + assert self.group in perm_user1.share_groups.all() + assert self.group in perm_user2.share_groups.all() + + # Step 3 - revoke group G's access + remove_response = self.client.post( + reverse( + "users:share_item", + kwargs={"item_type": "dataset", "item_uuid": self.dataset.uuid}, + ), + data={"remove_users": json.dumps([f"group:{self.group.uuid}"])}, + ) + assert remove_response.status_code == status.HTTP_200_OK + + # Step 4 - user1 and user2 keep access via their individual shares + perm_user1.refresh_from_db() + perm_user2.refresh_from_db() + assert perm_user1.is_enabled, ( + "user1's individual share must survive group revocation" + ) + assert perm_user2.is_enabled, ( + "user2's individual share must survive group revocation" + ) + assert UserSharePermission.user_can_view( + self.user1, self.dataset.uuid, ItemType.DATASET + ) + assert UserSharePermission.user_can_view( + self.user2, self.dataset.uuid, ItemType.DATASET + ) + + # Step 5 - owner retains access via dataset ownership + assert UserSharePermission.user_can_view( + self.owner, self.dataset.uuid, ItemType.DATASET + ) + def test_update_group_permission_unauthorized(self): """Test that users cannot update group permissions they don't own.""" # Create group owned by user1 diff --git a/gateway/sds_gateway/users/tests/test_group_sharing.py b/gateway/sds_gateway/users/tests/test_group_sharing.py index 22686a17..11bbe246 100644 --- a/gateway/sds_gateway/users/tests/test_group_sharing.py +++ b/gateway/sds_gateway/users/tests/test_group_sharing.py @@ -260,7 +260,7 @@ def dataset(self, owner: User) -> Dataset: @pytest.fixture def share_group(self, owner: User, group_member: User) -> ShareGroup: group = ShareGroup.objects.create(name="Test Group", owner=owner) - group.members.add(group_member) + group.members.add(owner, group_member) return group def test_create_share_group(self, client: Client, owner: User) -> None: @@ -386,29 +386,32 @@ def test_cannot_remove_owner_from_group( assert owner in group.members.all() assert result["errors"] - def test_cannot_remove_last_member_from_group( + def test_cannot_remove_non_member_from_group( self, client: Client, owner: User, - group_member: User, share_group: ShareGroup, ) -> None: - """Test that the last member of a group cannot be removed.""" - # share_group fixture has only group_member as a member (owner not added) - assert share_group.members.count() == 1 + """Test that removing a user who is not in the group produces an error.""" + non_member = User.objects.create_user( + email="nonmember@example.com", + password=TEST_PASSWORD, + name="Non Member", + is_approved=True, + ) client.force_login(owner) url = reverse("users:share_group_list") data = { "action": "remove_members", "group_uuid": str(share_group.uuid), - "user_emails": group_member.email, + "user_emails": non_member.email, } response = client.post(url, data) assert response.status_code == status.HTTP_200_OK result = response.json() - assert group_member in share_group.members.all() + assert non_member not in share_group.members.all() assert result["errors"] def test_delete_share_group( @@ -1131,21 +1134,12 @@ def test_individual_and_group_access_group_removed( group1: ShareGroup, ) -> None: """ - Test that asset is not accessible to individual when group - is removed from permission. - """ - # Share dataset both individually and through group - # Individual share - UserSharePermission.objects.create( - owner=owner, - shared_with=group_member, - item_type=ItemType.DATASET, - item_uuid=dataset.uuid, - is_enabled=True, - message="Individual share", - ) + Test that asset is not accessible when a group-only share is revoked. - # Group share + A permission created solely via a group share (is_individual_share=False) + must be disabled when the group is removed from it. + """ + # Group-only share — no prior individual share update_or_create_user_group_share_permissions( request_user=owner, group=group1,