Skip to content

Allow Editor and Reviewer profiles to transfer record ownership#9284

Draft
juanluisrp wants to merge 1 commit into
geonetwork:mainfrom
GeoCat:transfer-ownership
Draft

Allow Editor and Reviewer profiles to transfer record ownership#9284
juanluisrp wants to merge 1 commit into
geonetwork:mainfrom
GeoCat:transfer-ownership

Conversation

@juanluisrp

Copy link
Copy Markdown
Contributor

Summary

  • Editor users can now transfer ownership of records they own to any user within their own groups.
  • Reviewer users can transfer ownership of records belonging to their groups to any user within their groups.
  • The /users/groups endpoint now returns group members for Editor and Reviewer profiles (previously restricted to UserAdmin+), which is required to populate the transfer-ownership dialog.
  • UI visibility guards in selection-widget.html and mdactionmenu.html are relaxed from isUserAdminOrMore() to isEditorOrMore().

Test plan

  • Login as an Editor; verify the "Transfer ownership" action appears for owned records.
  • Transfer an owned record to another user in the same group — should succeed.
  • Attempt to transfer a record owned by another user — should be blocked (not editable).
  • Attempt to transfer to a group the Editor does not belong to — should be blocked.
  • Login as a Reviewer; verify the action appears for records in their group.
  • Transfer a group record to another user in the same group — should succeed.
  • Login as a plain Editor and call GET /srv/api/users/groups — should return users from their groups.
  • Integration tests (MetadataSharingApiTest) pass.

Previously only UserAdmin and above could transfer ownership of records.
This change extends the transfer-ownership feature to Editor and Reviewer
profiles, with the following restrictions:

- An Editor can only transfer records they own, and only to groups they
  belong to.
- A Reviewer can transfer records owned by their groups, and only to
  groups they belong to.

The /users/groups endpoint is updated so that Editor and Reviewer users
can retrieve the list of users in their groups (needed to populate the
transfer-ownership dialog).

The UI visibility guards (selection-widget and mdactionmenu) are relaxed
from isUserAdminOrMore() to isEditorOrMore() to expose the action to the
new eligible profiles.

Tests are added to MetadataSharingApiTest to cover the happy paths and
the rejection cases.
@juanluisrp
juanluisrp force-pushed the transfer-ownership branch from 238193c to 4fd05cf Compare May 19, 2026 09:54
@juanluisrp
juanluisrp requested a review from josegar74 May 19, 2026 09:59
return;
}
}
}

@josegar74 josegar74 May 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juanluisrp I would refactor a bit the code of the changes:

   private boolean isEditableMetadata(AbstractMetadata metadata, AccessManager accessMan, UserSession callerSession) throws Exception {
        Profile callerProfile = callerSession.getProfile();
        
        if (callerProfile == Profile.Editor || callerProfile == Profile.Reviewer) {
            int callerUserId = callerSession.getUserIdAsInt();
            Integer sourceUsr = metadata.getSourceInfo().getOwner();
            Integer sourceGrp = metadata.getSourceInfo().getGroupOwner();

            if (callerProfile == Profile.Editor) {
                if (!Objects.equals(sourceUsr, callerUserId)) {
                    return false;
                }
            } else {
                Set<Integer> reviewerGroups = accessMan.getReviewerGroups(callerSession);
                if (sourceGrp == null || !reviewerGroups.contains(sourceGrp)) {
                    return false;
                }
            }

           return true;
        } else if (callerProfile == Profile.Administrator) {
            return true;
        } else {
            return false;
        }
    }

    private boolean isDestinationTransferGroupAllowed(Integer groupIdentifier, UserSession callerSession) {
        Profile callerProfile = callerSession.getProfile();

        if (callerProfile == Profile.Editor || callerProfile == Profile.Reviewer) {
            int callerUserId = callerSession.getUserIdAsInt();

            if (!ReservedGroup.isReserved(groupIdentifier)) {
                List<Integer> myGroups = userGroupRepository.findGroupIds(
                    UserGroupSpecs.hasUserId(callerUserId));
                if (!myGroups.contains(groupIdentifier)) {
                    return false;
                }
            }

            return true;
        } else if (callerProfile == Profile.Administrator) {
            return true;
        } else {
            return false;
        }
    }
...
            UserSession callerSession = ApiUtils.getUserSession(session);

            if (!isEditableMetadata(metadata, accessMan, callerSession)) {
                report.addNotEditableMetadataId(metadata.getId());
                return;
            }

            if (!isDestinationTransferGroupAllowed(groupIdentifier, callerSession)) {
                report.addMetadataInfos(metadata,
                    "Transfer to group " + groupIdentifier + " is not allowed for your profile.");
                return;
            }

            // Retrieve the identifiers associated with the metadata uuid.
            // When the workflow is enabled, the metadata can have an approved and a working copy version.
...

Comment on lines +189 to +202
} else if (myProfile == Profile.Reviewer || myProfile == Profile.Editor) {
List<Integer> myGroups = userGroupRepository.findAll(UserGroupSpecs.hasUserId(session.getUserIdAsInt()))
.stream().map(ug -> ug.getGroup().getId()).collect(Collectors.toList());
List<UserGroup> userGroups = userGroupRepository.findAll(UserGroupSpecs.hasGroupIds(myGroups));

if (groupTypes != null) {
userGroups = userGroups.stream()
.filter(ug -> groupTypes.contains(ug.getGroup().getType()))
.collect(Collectors.toList());
}
for (UserGroup ug : userGroups) {
list.add(new UserGroupsResponse(ug.getUser(), ug.getGroup(), ug.getProfile().name()));
}
return list;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would refactor, with previous lines also:

    private List<UserGroupsResponse> getUserGroupsResponse(List<UserGroup> userGroups, List<GroupType> groupTypes) {
        List<UserGroupsResponse> list =  new ArrayList<>();

        if (groupTypes != null) {
            userGroups = userGroups.stream()
                .filter(ug -> groupTypes.contains(ug.getGroup().getType()))
                .collect(Collectors.toList());
        }

        for (UserGroup ug : userGroups) {
            list.add(new UserGroupsResponse(ug.getUser(), ug.getGroup(), ug.getProfile().name()));
        }

        return list;
    }
...
           list.addAll(getUserGroupsResponse(userGroups, groupTypes));
            
            return list;

        } else if (myProfile == Profile.Reviewer || myProfile == Profile.Editor) {
            List<Integer> myGroups = userGroupRepository.findAll(UserGroupSpecs.hasUserId(session.getUserIdAsInt()))
                .stream().map(ug -> ug.getGroup().getId()).collect(Collectors.toList());
            List<UserGroup> userGroups = userGroupRepository.findAll(UserGroupSpecs.hasGroupIds(myGroups));

            return getUserGroupsResponse(userGroups, groupTypes);
        } else {
            throw new SecurityException("You don't have rights to do get the groups for this user");
        }
...

return;
}
}
}

@josegar74 josegar74 May 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juanluisrp I would refactor a bit the code of the changes:

   private boolean isEditableMetadata(AbstractMetadata metadata, AccessManager accessMan, UserSession callerSession) throws Exception {
        Profile callerProfile = callerSession.getProfile();
        
        if (callerProfile == Profile.Editor || callerProfile == Profile.Reviewer) {
            int callerUserId = callerSession.getUserIdAsInt();
            Integer sourceUsr = metadata.getSourceInfo().getOwner();
            Integer sourceGrp = metadata.getSourceInfo().getGroupOwner();

            if (callerProfile == Profile.Editor) {
                if (!Objects.equals(sourceUsr, callerUserId)) {
                    return false;
                }
            } else {
                Set<Integer> reviewerGroups = accessMan.getReviewerGroups(callerSession);
                if (sourceGrp == null || !reviewerGroups.contains(sourceGrp)) {
                    return false;
                }
            }
        } else if (callerProfile == Profile.Administrator) {
            return true;
        }
        
        return false;
    }

    private boolean isDestinationTransferGroupAllowed(Integer groupIdentifier, UserSession callerSession) {
        Profile callerProfile = callerSession.getProfile();

        if (callerProfile == Profile.Editor || callerProfile == Profile.Reviewer) {
            int callerUserId = callerSession.getUserIdAsInt();

            if (!ReservedGroup.isReserved(groupIdentifier)) {
                List<Integer> myGroups = userGroupRepository.findGroupIds(
                    UserGroupSpecs.hasUserId(callerUserId));
                if (!myGroups.contains(groupIdentifier)) {
                    return false;
                }
            }

            return true;
        } else if (callerProfile == Profile.Administrator) {
            return true;
        }
        
        return false;
    }
...
            UserSession callerSession = ApiUtils.getUserSession(session);

            if (!isEditableMetadata(metadata, accessMan, callerSession)) {
                report.addNotEditableMetadataId(metadata.getId());
                return;
            }

            if (!isDestinationTransferGroupAllowed(groupIdentifier, callerSession)) {
                report.addMetadataInfos(metadata,
                    "Transfer to group " + groupIdentifier + " is not allowed for your profile.");
                return;
            }

            // Retrieve the identifiers associated with the metadata uuid.
            // When the workflow is enabled, the metadata can have an approved and a working copy version.
...

return;
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juanluisrp I would refactor a bit the code of the changes:

   private boolean isEditableMetadata(AbstractMetadata metadata, AccessManager accessMan, UserSession callerSession) throws Exception {
        Profile callerProfile = callerSession.getProfile();
        
        if (callerProfile == Profile.Editor || callerProfile == Profile.Reviewer) {
            int callerUserId = callerSession.getUserIdAsInt();
            Integer sourceUsr = metadata.getSourceInfo().getOwner();
            Integer sourceGrp = metadata.getSourceInfo().getGroupOwner();

            if (callerProfile == Profile.Editor) {
                if (!Objects.equals(sourceUsr, callerUserId)) {
                    return false;
                }
            } else {
                Set<Integer> reviewerGroups = accessMan.getReviewerGroups(callerSession);
                if (sourceGrp == null || !reviewerGroups.contains(sourceGrp)) {
                    return false;
                }
            }
        } else if (callerProfile == Profile.Administrator) {
            return true;
        }
        
        return false;
    }

    private boolean isDestinationTransferGroupAllowed(Integer groupIdentifier, UserSession callerSession) {
        Profile callerProfile = callerSession.getProfile();

        if (callerProfile == Profile.Editor || callerProfile == Profile.Reviewer) {
            int callerUserId = callerSession.getUserIdAsInt();

            if (!ReservedGroup.isReserved(groupIdentifier)) {
                List<Integer> myGroups = userGroupRepository.findGroupIds(
                    UserGroupSpecs.hasUserId(callerUserId));
                if (!myGroups.contains(groupIdentifier)) {
                    return false;
                }
            }

            return true;
        } else if (callerProfile == Profile.Administrator) {
            return true;
        } else {
            return false;
        }
    }
...
            UserSession callerSession = ApiUtils.getUserSession(session);

            if (!isEditableMetadata(metadata, accessMan, callerSession)) {
                report.addNotEditableMetadataId(metadata.getId());
                return;
            }

            if (!isDestinationTransferGroupAllowed(groupIdentifier, callerSession)) {
                report.addMetadataInfos(metadata,
                    "Transfer to group " + groupIdentifier + " is not allowed for your profile.");
                return;
            }

            // Retrieve the identifiers associated with the metadata uuid.
            // When the workflow is enabled, the metadata can have an approved and a working copy version.
...

@josegar74 josegar74 added this to the 4.4.12 milestone Jun 1, 2026
@fxprunayre fxprunayre modified the milestones: 4.4.12, 4.4.13 Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants