From 9476a41cdedc9947859e493563c9fed6aebf9809 Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 24 Mar 2026 10:46:29 +0100 Subject: [PATCH 01/12] feat: require only LinkDataverse permission to link and unlink collections via API (no superuser) --- .../impl/DeleteDataverseLinkingDataverseCommand.java | 6 +----- .../dataverse/engine/command/impl/LinkDataverseCommand.java | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDataverseLinkingDataverseCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDataverseLinkingDataverseCommand.java index 9eddfcd2b9c..33f88d8d768 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDataverseLinkingDataverseCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDataverseLinkingDataverseCommand.java @@ -26,7 +26,7 @@ * @author sarahferry */ -@RequiredPermissions( Permission.EditDataverse ) +@RequiredPermissions( Permission.LinkDataverse ) public class DeleteDataverseLinkingDataverseCommand extends AbstractCommand { private final DataverseLinkingDataverse doomed; @@ -42,10 +42,6 @@ public DeleteDataverseLinkingDataverseCommand(DataverseRequest aRequest, Dataver @Override public Dataverse execute(CommandContext ctxt) throws CommandException { - if ((!(getUser() instanceof AuthenticatedUser) || !getUser().isSuperuser())) { - throw new PermissionException("Delete dataverse linking dataverse can only be called by superusers.", - this, Collections.singleton(Permission.DeleteDataverse), editedDv); - } Dataverse merged = ctxt.em().merge(editedDv); DataverseLinkingDataverse doomedAndMerged = ctxt.em().merge(doomed); ctxt.em().remove(doomedAndMerged); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/LinkDataverseCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/LinkDataverseCommand.java index 2e1aecc9a84..3561aba076c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/LinkDataverseCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/LinkDataverseCommand.java @@ -45,10 +45,6 @@ public LinkDataverseCommand(DataverseRequest aRequest, Dataverse dataverse, Data @Override public DataverseLinkingDataverse execute(CommandContext ctxt) throws CommandException { - if ((!(getUser() instanceof AuthenticatedUser) || !getUser().isSuperuser())) { - throw new PermissionException("Link Dataverse can only be called by superusers.", - this, Collections.singleton(Permission.LinkDataverse), linkingDataverse); - } if (linkedDataverse.equals(linkingDataverse)) { throw new IllegalCommandException("Can't link a dataverse to itself", this); } From 17e4829e26a818d6f187e7033bd653d4ae7022db Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 24 Mar 2026 10:48:27 +0100 Subject: [PATCH 02/12] feat: show "Link" button for collections in UI to non-superusers --- src/main/java/edu/harvard/iq/dataverse/DataversePage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java index d9cafbf421a..3776b9db094 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java @@ -218,7 +218,7 @@ public boolean showLinkingPopup() { testquery = query; } - return (session.getUser().isSuperuser() && (dataverse.getOwner() != null || !testquery.isEmpty())); + return (dataverse.getOwner() != null || !testquery.isEmpty()); } public void setupLinkingPopup (String popupSetting){ From a752d339510cf654aaa94a655bd7840e2fd1c439 Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 24 Mar 2026 10:50:28 +0100 Subject: [PATCH 03/12] docs: add/update docs about linking --- .../source/admin/dataverses-datasets.rst | 4 +- doc/sphinx-guides/source/api/native-api.rst | 60 +++++++++++++++++++ .../source/user/dataverse-management.rst | 8 +-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/doc/sphinx-guides/source/admin/dataverses-datasets.rst b/doc/sphinx-guides/source/admin/dataverses-datasets.rst index 9696c758b04..308ff43b369 100644 --- a/doc/sphinx-guides/source/admin/dataverses-datasets.rst +++ b/doc/sphinx-guides/source/admin/dataverses-datasets.rst @@ -22,14 +22,14 @@ Moves a Dataverse collection whose id is passed to an existing Dataverse collect Link a Dataverse Collection ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Creates a link between a Dataverse collection and another Dataverse collection (see the :ref:`dataverse-linking` section of the User Guide for more information). Only accessible to superusers. :: +Creates a link between a Dataverse collection and another Dataverse collection (see the :ref:`dataverse-linking` section of the User Guide for more information). :: curl -H "X-Dataverse-key: $API_TOKEN" -X PUT http://$SERVER/api/dataverses/$linked-dataverse-alias/link/$linking-dataverse-alias Unlink a Dataverse Collection ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Removes a link between a Dataverse collection and another Dataverse collection. Only accessible to superusers. :: +Removes a link between a Dataverse collection and another Dataverse collection. Accessible to users with Link Dataverse permission on the linking Dataverse collection. :: curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE http://$SERVER/api/dataverses/$linked-dataverse-alias/deleteLink/$linking-dataverse-alias diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index c91c45defae..e9c6c992cbf 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3475,6 +3475,66 @@ The fully expanded example above (without environment variables) looks like this curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/datasets/24/link/test" +Unlink a Dataset +~~~~~~~~~~~~~~~~ + +Removes a link between a dataset and a Dataverse collection (see :ref:`dataset-linking` section of Dataverse Collection Management in the User Guide for more information): + +.. code-block:: bash + +export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +export SERVER_URL=https://demo.dataverse.org +export DATASET_ID=24 +export DATAVERSE_ID=test + +curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/datasets/$DATASET_ID/deleteLink/$DATAVERSE_ID" + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + +curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X DELETE "https://demo.dataverse.org/api/datasets/24/deleteLink/test" + +Link a Dataverse collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Creates a link between one Dataverse collection and another Dataverse collection (see :ref:`dataverse-linking` section of Dataverse Collection Management in the User Guide for more information): + +.. code-block:: bash + +export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +export SERVER_URL=https://demo.dataverse.org +export LINKED_DATAVERSE_ID=linked-collection +export LINKING_DATAVERSE_ID=linking-collection + +curl -H "X-Dataverse-key: $API_TOKEN" -X PUT "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/link/$LINKING_DATAVERSE_ID" + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + +curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/dataverses/linked-collection/link/linking-collection" + +Unlink a Dataverse collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Removes a link between one Dataverse collection and another Dataverse collection (see :ref:`dataverse-linking` section of Dataverse Collection Management in the User Guide for more information): + +.. code-block:: bash + +export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +export SERVER_URL=https://demo.dataverse.org +export LINKED_DATAVERSE_ID=linked-collection +export LINKING_DATAVERSE_ID=linking-collection + +curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/deleteLink/$LINKING_DATAVERSE_ID" + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + +curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X DELETE "https://demo.dataverse.org/api/dataverses/linked-collection/deleteLink/linking-collection" + Dataset Locks ~~~~~~~~~~~~~ diff --git a/doc/sphinx-guides/source/user/dataverse-management.rst b/doc/sphinx-guides/source/user/dataverse-management.rst index 4e94bfad256..2586a6e90e0 100755 --- a/doc/sphinx-guides/source/user/dataverse-management.rst +++ b/doc/sphinx-guides/source/user/dataverse-management.rst @@ -221,18 +221,18 @@ In order to link a dataset, you will need your account to have the "Link Dataset To link a dataset to your Dataverse collection, you must navigate to that dataset and click the white "Link" button in the upper-right corner of the dataset page. This will open up a window where you can type in the name of the Dataverse collection that you would like to link the dataset to. Select your Dataverse collection and click the save button. This will establish the link, and the dataset will now appear under your Dataverse collection. -A draft dataset can be linked to other Dataverse collections. It will only become publicly visible in the linked collection(s) after it has been published. To publish the dataset, your account must have the "Publish Dataset" permission for the Dataverse collection in which the dataset was originally created. Permissions in the linked Dataverse collections do not apply. +To remove an established link, navigate to the linked dataset's page and click the white "Unlink" button in the upper-right corner of the page. -There is currently no way to remove established links in the UI. If you need to remove a link between a Dataverse collection and a dataset, please contact the support team for the Dataverse installation you are using (see the :ref:`unlink-a-dataset` section of the Admin Guide for more information). +A draft dataset can be linked to other Dataverse collections. It will only become publicly visible in the linked collection(s) after it has been published. To publish the dataset, your account must have the "Publish Dataset" permission for the Dataverse collection in which the dataset was originally created. Permissions in the linked Dataverse collections do not apply. .. _dataverse-linking: Dataverse Collection Linking ============================ -Similarly to dataset linking, Dataverse collection linking allows a Dataverse collection owner to "link" their Dataverse collection to another Dataverse collection, so the Dataverse collection being linked will appear in the linking Dataverse collection's list of contents without actually *being* in that Dataverse collection. Currently, the ability to link a Dataverse collection to another Dataverse collection is a superuser only feature. +Similarly to dataset linking, Dataverse collection linking allows a Dataverse collection owner to "link" their Dataverse collection to another Dataverse collection, so the Dataverse collection being linked will appear in the linking Dataverse collection's list of contents without actually *being* in that Dataverse collection. -If you need to have a Dataverse collection linked to your Dataverse collection, please contact the support team for the Dataverse installation you are using. +In order to link a collection, you will need your account to have the "Link Dataverse" permission on the linking Dataverse collection. Publish Your Dataverse Collection ================================= From 837cf5de977a3c8192ec65d0ec4d29d76df964ed Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 24 Mar 2026 10:51:08 +0100 Subject: [PATCH 04/12] test: update LinkIT to test required permissions for collection linking + unlinking --- .../edu/harvard/iq/dataverse/api/LinkIT.java | 86 ++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java b/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java index dfc132c3b3f..a8658efa076 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java @@ -105,56 +105,82 @@ public void testLinkedDataset() { @Test public void testCreateDeleteDataverseLink() { - Response createUser = UtilIT.createRandomUser(); + // Create user #1 who owns Dataverse collection #1 + Response createUser1 = UtilIT.createRandomUser(); + createUser1.prettyPrint(); + String apiToken1 = UtilIT.getApiTokenFromResponse(createUser1); + String username1 = UtilIT.getUsernameFromResponse(createUser1); - createUser.prettyPrint(); - String username = UtilIT.getUsernameFromResponse(createUser); - String apiToken = UtilIT.getApiTokenFromResponse(createUser); + Response createDataverse1Response = UtilIT.createRandomDataverse(apiToken1); + createDataverse1Response.prettyPrint(); + String dataverse1Alias = UtilIT.getAliasFromResponse(createDataverse1Response); + Integer dataverse1Id = UtilIT.getDataverseIdFromResponse(createDataverse1Response); - Response superuserResponse = UtilIT.makeSuperUser(username); + // Create user #2 who owns Dataverse collection #2 + Response createUser2 = UtilIT.createRandomUser(); + createUser2.prettyPrint(); + String apiToken2 = UtilIT.getApiTokenFromResponse(createUser2); - Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); - createDataverseResponse.prettyPrint(); - String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); - Integer dataverseId = UtilIT.getDataverseIdFromResponse(createDataverseResponse); + Response createDataverse2Response = UtilIT.createRandomDataverse(apiToken2); + createDataverse2Response.prettyPrint(); + String dataverse2Alias = UtilIT.getAliasFromResponse(createDataverse2Response); + Integer dataverse2Id = UtilIT.getDataverseIdFromResponse(createDataverse2Response); - Response createDataverseResponse2 = UtilIT.createRandomDataverse(apiToken); - createDataverseResponse2.prettyPrint(); - String dataverseAlias2 = UtilIT.getAliasFromResponse(createDataverseResponse2); - Integer dataverseId2 = UtilIT.getDataverseIdFromResponse(createDataverseResponse2); + // Let user #1 try to link their collection #1 into collection #2 + // This should fail, because user #1 has not been granted permission to link into collection #2 + Response createDataverseLinkWithoutPermissionResponse = UtilIT.createDataverseLink(dataverse1Alias, dataverse2Alias, apiToken1); + createDataverseLinkWithoutPermissionResponse.prettyPrint(); + createDataverseLinkWithoutPermissionResponse.then().assertThat() + .statusCode(UNAUTHORIZED.getStatusCode()); - Response createLinkingDataverseResponse = UtilIT.createDataverseLink(dataverseAlias, dataverseAlias2, apiToken); - createLinkingDataverseResponse.prettyPrint(); - createLinkingDataverseResponse.then().assertThat() - .statusCode(OK.getStatusCode()) - .body("data.message", equalTo("Dataverse " + dataverseAlias + " linked successfully to " + dataverseAlias2)); + // Let user #2 grant user #1 admin access for collection #2 + // (The admin role is the only preconfigured role which includes the "Link Dataverse" permission) + Response grantUser2AccessOnDataverse1 = UtilIT.grantRoleOnDataverse(dataverse2Alias, "admin", "@" + username1, apiToken2); + grantUser2AccessOnDataverse1.prettyPrint(); + grantUser2AccessOnDataverse1.then().assertThat() + .statusCode(OK.getStatusCode()); - Response tryLinkingAgain = UtilIT.createDataverseLink(dataverseAlias, dataverseAlias2, apiToken); + // Let user #1 try again + // Now that they have access, this should succeed + Response tryLinkingAgain = UtilIT.createDataverseLink(dataverse1Alias, dataverse2Alias, apiToken1); tryLinkingAgain.prettyPrint(); tryLinkingAgain.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo("Dataverse " + dataverse1Alias + " linked successfully to " + dataverse2Alias)); + + // And again, to see if the creation of duplicate links is correctly rejected + Response tryLinkingAgainAndAgain = UtilIT.createDataverseLink(dataverse1Alias, dataverse2Alias, apiToken1); + tryLinkingAgainAndAgain.prettyPrint(); + tryLinkingAgainAndAgain.then().assertThat() .statusCode(FORBIDDEN.getStatusCode()) - .body("message", equalTo(dataverseAlias + " has already been linked to " + dataverseAlias2 + ".")); + .body("message", equalTo(dataverse1Alias + " has already been linked to " + dataverse2Alias + ".")); - Response getLinksResponse = UtilIT.getDataverseLinks(dataverseAlias, apiToken); + // Make user #1 superuser because it's required to list a collection's links + UtilIT.setSuperuserStatus(username1, true); + + Response getLinksResponse = UtilIT.getDataverseLinks(dataverse1Alias, apiToken1); getLinksResponse.prettyPrint(); getLinksResponse.then().assertThat() .statusCode(OK.getStatusCode()) - .body("data.dataversesLinkingToThis[0].id", equalTo(dataverseId2)) - .body("data.dataversesLinkingToThis[0].alias", equalTo(dataverseAlias2)) - .body("data.dataversesLinkingToThis[0].displayName", equalTo(dataverseAlias2)); - getLinksResponse = UtilIT.getDataverseLinks(dataverseAlias2, apiToken); + .body("data.dataversesLinkingToThis[0].id", equalTo(dataverse2Id)) + .body("data.dataversesLinkingToThis[0].alias", equalTo(dataverse2Alias)) + .body("data.dataversesLinkingToThis[0].displayName", equalTo(dataverse2Alias)); + getLinksResponse = UtilIT.getDataverseLinks(dataverse2Alias, apiToken1); getLinksResponse.prettyPrint(); getLinksResponse.then().assertThat() .statusCode(OK.getStatusCode()) - .body("data.linkedDataverses[0].id", equalTo(dataverseId)) - .body("data.linkedDataverses[0].alias", equalTo(dataverseAlias)) - .body("data.linkedDataverses[0].displayName", equalTo(dataverseAlias)); + .body("data.linkedDataverses[0].id", equalTo(dataverse1Id)) + .body("data.linkedDataverses[0].alias", equalTo(dataverse1Alias)) + .body("data.linkedDataverses[0].displayName", equalTo(dataverse1Alias)); + + // Undo superuser status to test that it's not required for deleting a link + UtilIT.setSuperuserStatus(username1, false); - Response deleteLinkingDataverseResponse = UtilIT.deleteDataverseLink(dataverseAlias, dataverseAlias2, apiToken); + Response deleteLinkingDataverseResponse = UtilIT.deleteDataverseLink(dataverse1Alias, dataverse2Alias, apiToken1); deleteLinkingDataverseResponse.prettyPrint(); deleteLinkingDataverseResponse.then().assertThat() .statusCode(OK.getStatusCode()) - .body("data.message", equalTo("Link from Dataverse " + dataverseAlias + " to linked Dataverse " + dataverseAlias2 + " deleted")); + .body("data.message", equalTo("Link from Dataverse " + dataverse2Alias + " to linked Dataverse " + dataverse1Alias + " deleted")); } @Test From 6ce0b91e2bb7a22ee4873069638f42219eb2a9ea Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 24 Mar 2026 10:53:26 +0100 Subject: [PATCH 05/12] fix: fix mix-up of linking + linked collection when finding/deleting collection links --- .../iq/dataverse/DataverseLinkingServiceBean.java | 10 +++++----- .../java/edu/harvard/iq/dataverse/api/Dataverses.java | 2 +- .../search/savedsearch/SavedSearchServiceBean.java | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java index 9f1bcde4c0e..cd806d250d4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java @@ -89,19 +89,19 @@ public void save(DataverseLinkingDataverse dataverseLinkingDataverse) { } } - public DataverseLinkingDataverse findDataverseLinkingDataverse(Long dataverseId, Long linkingDataverseId) { + public DataverseLinkingDataverse findDataverseLinkingDataverse(Long linkingDataverseId, Long linkedDataverseId) { try { return em.createNamedQuery("DataverseLinkingDataverse.findByDataverseIdAndLinkingDataverseId", DataverseLinkingDataverse.class) - .setParameter("dataverseId", dataverseId) + .setParameter("dataverseId", linkedDataverseId) .setParameter("linkingDataverseId", linkingDataverseId) .getSingleResult(); } catch (jakarta.persistence.NoResultException e) { - logger.fine("No DataverseLinkingDataverse found for dataverseId " + dataverseId + " and linkedDataverseId " + linkingDataverseId); + logger.fine("No DataverseLinkingDataverse found for linkingDataverseId " + linkingDataverseId + " and linkedDataverseId " + linkedDataverseId); return null; } } - public boolean alreadyLinked(Dataverse definitionPoint, Dataverse dataverseToLinkTo) { - return findDataverseLinkingDataverse(dataverseToLinkTo.getId(), definitionPoint.getId()) != null; + public boolean alreadyLinked(Dataverse linkingDataverse, Dataverse linkedDataverse) { + return findDataverseLinkingDataverse(linkingDataverse.getId(), linkedDataverse.getId()) != null; } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java index caf778be675..97316d79a41 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java @@ -843,7 +843,7 @@ private List parseFacets(JsonArray facetsArray) throws Wrapped @DELETE @AuthRequired - @Path("{linkingDataverseId}/deleteLink/{linkedDataverseId}") + @Path("{linkedDataverseId}/deleteLink/{linkingDataverseId}") public Response deleteDataverseLinkingDataverse(@Context ContainerRequestContext crc, @PathParam("linkingDataverseId") String linkingDataverseId, @PathParam("linkedDataverseId") String linkedDataverseId) { boolean index = true; return response(req -> { diff --git a/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java index 4840131a57e..fcb423fa492 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java @@ -276,7 +276,7 @@ public void removeLinks(DataverseRequest dvReq, SavedSearch savedSearch) throws if (dvObjectThatDefinitionPointWillLinkTo.isInstanceofDataverse()) { Dataverse linkedDataverse = (Dataverse) dvObjectThatDefinitionPointWillLinkTo; - DataverseLinkingDataverse dvld = dvLinkingService.findDataverseLinkingDataverse(linkedDataverse.getId(), linkingDataverse.getId()); + DataverseLinkingDataverse dvld = dvLinkingService.findDataverseLinkingDataverse(linkingDataverse.getId(), linkedDataverse.getId()); if(dvld != null) { Dataverse dv = commandEngine.submitInNewTransaction(new DeleteDataverseLinkingDataverseCommand(dvReq, linkingDataverse, dvld, true)); } From 02ae1be94aed40bf85993033344f43c0aa92cf1d Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 24 Mar 2026 11:45:25 +0100 Subject: [PATCH 06/12] docs: add release note for #12076 --- doc/release-notes/12076-non-superuser-dataverse-linking.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/release-notes/12076-non-superuser-dataverse-linking.md diff --git a/doc/release-notes/12076-non-superuser-dataverse-linking.md b/doc/release-notes/12076-non-superuser-dataverse-linking.md new file mode 100644 index 00000000000..6ed55b65031 --- /dev/null +++ b/doc/release-notes/12076-non-superuser-dataverse-linking.md @@ -0,0 +1 @@ +Dataverse collection linking and unlinking no longer requires superuser status. Users with the "Link Dataverse" permission on a collection can now perform these actions through the UI and API. From 279544695077942ac603354bc01f88f62f2a634d Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Thu, 9 Apr 2026 14:49:40 +0200 Subject: [PATCH 07/12] docs: add missing indentation to docs about linking --- doc/sphinx-guides/source/api/native-api.rst | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index e9c6c992cbf..e0a9c96296d 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3482,18 +3482,18 @@ Removes a link between a dataset and a Dataverse collection (see :ref:`dataset-l .. code-block:: bash -export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -export SERVER_URL=https://demo.dataverse.org -export DATASET_ID=24 -export DATAVERSE_ID=test + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export DATASET_ID=24 + export DATAVERSE_ID=test -curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/datasets/$DATASET_ID/deleteLink/$DATAVERSE_ID" + curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/datasets/$DATASET_ID/deleteLink/$DATAVERSE_ID" The fully expanded example above (without environment variables) looks like this: .. code-block:: bash -curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X DELETE "https://demo.dataverse.org/api/datasets/24/deleteLink/test" + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X DELETE "https://demo.dataverse.org/api/datasets/24/deleteLink/test" Link a Dataverse collection ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3502,18 +3502,18 @@ Creates a link between one Dataverse collection and another Dataverse collection .. code-block:: bash -export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -export SERVER_URL=https://demo.dataverse.org -export LINKED_DATAVERSE_ID=linked-collection -export LINKING_DATAVERSE_ID=linking-collection + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export LINKED_DATAVERSE_ID=linked-collection + export LINKING_DATAVERSE_ID=linking-collection -curl -H "X-Dataverse-key: $API_TOKEN" -X PUT "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/link/$LINKING_DATAVERSE_ID" + curl -H "X-Dataverse-key: $API_TOKEN" -X PUT "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/link/$LINKING_DATAVERSE_ID" The fully expanded example above (without environment variables) looks like this: .. code-block:: bash -curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/dataverses/linked-collection/link/linking-collection" + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/dataverses/linked-collection/link/linking-collection" Unlink a Dataverse collection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3522,18 +3522,18 @@ Removes a link between one Dataverse collection and another Dataverse collection .. code-block:: bash -export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -export SERVER_URL=https://demo.dataverse.org -export LINKED_DATAVERSE_ID=linked-collection -export LINKING_DATAVERSE_ID=linking-collection + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export LINKED_DATAVERSE_ID=linked-collection + export LINKING_DATAVERSE_ID=linking-collection -curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/deleteLink/$LINKING_DATAVERSE_ID" + curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/deleteLink/$LINKING_DATAVERSE_ID" The fully expanded example above (without environment variables) looks like this: .. code-block:: bash -curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X DELETE "https://demo.dataverse.org/api/dataverses/linked-collection/deleteLink/linking-collection" + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X DELETE "https://demo.dataverse.org/api/dataverses/linked-collection/deleteLink/linking-collection" Dataset Locks ~~~~~~~~~~~~~ From 4b3d572d94d33c84dce4bc0828383e48b05d8f74 Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 19 May 2026 17:03:49 +0200 Subject: [PATCH 08/12] feat: only show eligible collections to link to on DataversePage --- .../harvard/iq/dataverse/DataversePage.java | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java index 3776b9db094..852cdebb14e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java @@ -233,35 +233,19 @@ public void setupLinkingPopup (String popupSetting){ public void updateLinkableDataverses() { dataversesForLinking = new ArrayList<>(); linkingDVSelectItems = new ArrayList<>(); - - //Since only a super user function add all dvs - dataversesForLinking = dataverseService.findAll();// permissionService.getDataversesUserHasPermissionOn(session.getUser(), Permission.PublishDataverse); - - /* - List roles = dataverseRoleServiceBean.getDataverseRolesByPermission(Permission.PublishDataverse, dataverse.getId()); - List types = new ArrayList(); - types.add("Dataverse"); - for (Long dvIdAsInt : permissionService.getDvObjectIdsUserHasRoleOn(session.getUser(), roles, types, false)) { - dataversesForLinking.add(dataverseService.find(dvIdAsInt)); - }*/ - - //for linking - make sure the link hasn't occurred and its not int the tree - if (this.linkMode.equals(LinkMode.LINKDATAVERSE)) { - - // remove this and it's parent tree - dataversesForLinking.remove(dataverse); - Dataverse testDV = dataverse; - while(testDV.getOwner() != null){ - dataversesForLinking.remove(testDV.getOwner()); - testDV = testDV.getOwner(); - } - - for (Dataverse removeLinked : linkingService.findLinkingDataverses(dataverse.getId())) { - dataversesForLinking.remove(removeLinked); - } - } else{ - //for saved search add all + + Permission permToCheck = (this.linkMode.equals(LinkMode.SAVEDSEARCH)) ? Permission.PublishDataverse : Permission.LinkDataverse; + List dvsWithLinkPermission = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), getAuthenticatedUser(), permToCheck, ""); + + if (dvsWithLinkPermission != null && !dvsWithLinkPermission.isEmpty()) { + // for linking - make sure the link hasn't occurred and it's not in the tree + if (this.linkMode.equals(LinkMode.LINKDATAVERSE)) { + dataversesForLinking = dataverseService.removeUnlinkableDataverses(dvsWithLinkPermission, dataverse); + } else { + // for saved search, add all + dataversesForLinking = dvsWithLinkPermission; + } } for (Dataverse selectDV : dataversesForLinking) { From 0694268fdfde06ade6e8b7d04eb887b3353fcc89 Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 19 May 2026 17:04:46 +0200 Subject: [PATCH 09/12] feat: let CreateSavedSearchCommand require linking permission (for consistency) --- src/main/java/edu/harvard/iq/dataverse/DataversePage.java | 3 +-- .../engine/command/impl/CreateSavedSearchCommand.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java index 852cdebb14e..a02195084e0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java @@ -235,8 +235,7 @@ public void updateLinkableDataverses() { linkingDVSelectItems = new ArrayList<>(); - Permission permToCheck = (this.linkMode.equals(LinkMode.SAVEDSEARCH)) ? Permission.PublishDataverse : Permission.LinkDataverse; - List dvsWithLinkPermission = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), getAuthenticatedUser(), permToCheck, ""); + List dvsWithLinkPermission = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), getAuthenticatedUser(), Permission.LinkDataverse, ""); if (dvsWithLinkPermission != null && !dvsWithLinkPermission.isEmpty()) { // for linking - make sure the link hasn't occurred and it's not in the tree diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateSavedSearchCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateSavedSearchCommand.java index 7a549a51dd5..ff3572e2ee8 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateSavedSearchCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateSavedSearchCommand.java @@ -14,7 +14,7 @@ import java.util.logging.Logger; import jakarta.json.JsonObjectBuilder; -@RequiredPermissions(Permission.PublishDataverse) +@RequiredPermissions(Permission.LinkDataverse) public class CreateSavedSearchCommand extends AbstractCommand { private static final Logger logger = Logger.getLogger(SavedSearchServiceBean.class.getCanonicalName()); From 935863234b40906c13f004fc1a6c37778440bc2c Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Tue, 19 May 2026 17:05:15 +0200 Subject: [PATCH 10/12] feat: update text shown in linking dialog when there are no eligible collections left --- src/main/webapp/dataverse.xhtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/webapp/dataverse.xhtml b/src/main/webapp/dataverse.xhtml index 4ffac1c24b3..0239f70005e 100644 --- a/src/main/webapp/dataverse.xhtml +++ b/src/main/webapp/dataverse.xhtml @@ -831,7 +831,7 @@

- #{bundle['dataverse.link.no.linkable']} + #{bundle['dataverse.link.no.linkable.remaining']}

- + From a1e422522f50717235e346874eabc4e515e90804 Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Thu, 28 May 2026 11:22:21 +0200 Subject: [PATCH 12/12] fix: fix display logic for "Link" button on collection page --- .../harvard/iq/dataverse/DataversePage.java | 23 ++++++++--- .../iq/dataverse/DataverseServiceBean.java | 39 +++++++++++-------- src/main/webapp/dataverse.xhtml | 3 +- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java index a02195084e0..949bf1e1fe4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java @@ -207,18 +207,31 @@ public void setLinkMode(LinkMode linkMode) { } public boolean showLinkingPopup() { - String testquery = ""; - if (session.getUser() == null) { + // Must be logged in + AuthenticatedUser au = getAuthenticatedUser(); + if (au == null) { return false; } if (dataverse == null) { return false; } - if (query != null) { - testquery = query; + + // If there is an active search query, that's all that matters (plus having permission on ANY collection) + if (query != null && !query.isEmpty()) { + List permitted = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), au, Permission.LinkDataverse); + return permitted != null && !permitted.isEmpty(); + } + + // Otherwise (no active search), check if there is at least one OTHER eligible collection + // Eligible means: not the current collection and not in the parent tree + // Technically, eligible also means "not already linked", but in that case, we show the Link button anyway and have the Link dialog display a message about all eligible collections already being linked + List dvsWithLinkPermission = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), au, Permission.LinkDataverse); + if (dvsWithLinkPermission != null && !dvsWithLinkPermission.isEmpty()) { + List eligibleDataverses = dataverseService.removeUnlinkableDataverses(dvsWithLinkPermission, dataverse, false); + return !eligibleDataverses.isEmpty(); } - return (dataverse.getOwner() != null || !testquery.isEmpty()); + return false; } public void setupLinkingPopup (String popupSetting){ diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java index 29cfb3fb42a..1816a76ed5c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java @@ -551,28 +551,30 @@ public List filterDataversesForLinking(String query, DataverseRequest return dataverseList; } - public List removeUnlinkableDataverses(List allWithPerms, DvObject dvo) { + public List removeUnlinkableDataverses(List allWithPerms, DvObject dvo, boolean removeAlreadyLinked) { List dataverseList = new ArrayList<>(); Dataset linkedDataset = null; Dataverse linkedDataverse = null; - List alreadyLinkeddv_ids; - - if ((dvo instanceof Dataset)) { - linkedDataset = (Dataset) dvo; - alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM datasetlinkingdataverse WHERE dataset_id = " + linkedDataset.getId()).getResultList(); - } else { - linkedDataverse = (Dataverse) dvo; - alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM dataverselinkingdataverse WHERE dataverse_id = " + linkedDataverse.getId()).getResultList(); - } List remove = new ArrayList<>(); - if (alreadyLinkeddv_ids != null && !alreadyLinkeddv_ids.isEmpty()) { - alreadyLinkeddv_ids.stream().map((testDVId) -> this.find(testDVId)).forEachOrdered((removeIt) -> { - remove.add(removeIt); - }); + if (removeAlreadyLinked) { + List alreadyLinkeddv_ids; + + if ((dvo instanceof Dataset)) { + linkedDataset = (Dataset) dvo; + alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM datasetlinkingdataverse WHERE dataset_id = " + linkedDataset.getId()).getResultList(); + } else { + linkedDataverse = (Dataverse) dvo; + alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM dataverselinkingdataverse WHERE dataverse_id = " + linkedDataverse.getId()).getResultList(); + } + + if (alreadyLinkeddv_ids != null && !alreadyLinkeddv_ids.isEmpty()) { + alreadyLinkeddv_ids.stream().map((testDVId) -> this.find(testDVId)).forEachOrdered((removeIt) -> { + remove.add(removeIt); + }); + } } - if (dvo instanceof Dataverse dataverse) { remove.add(dataverse); @@ -596,8 +598,11 @@ public List removeUnlinkableDataverses(List allWithPerms, return dataverseList; } - - + + public List removeUnlinkableDataverses(List allWithPerms, DvObject dvo) { + return removeUnlinkableDataverses(allWithPerms, dvo, true); + } + public List filterDataversesForUnLinking(String query, DataverseRequest req, Dataset dataset) { List alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM datasetlinkingdataverse WHERE dataset_id = " + dataset.getId()).getResultList(); List dataverseList = new ArrayList<>(); diff --git a/src/main/webapp/dataverse.xhtml b/src/main/webapp/dataverse.xhtml index 3c6214717f7..e06d51674f3 100644 --- a/src/main/webapp/dataverse.xhtml +++ b/src/main/webapp/dataverse.xhtml @@ -501,7 +501,8 @@
+ or permissionsWrapper.canIssuePublishDataverseCommand(DataversePage.dataverse) + or DataversePage.showLinkingPopup())}">