diff --git a/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java b/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java index e74363c3280..64c96dfa28f 100644 --- a/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java +++ b/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java @@ -67,6 +67,7 @@ import org.fao.geonet.kernel.schema.AssociatedResource; import org.fao.geonet.kernel.schema.AssociatedResourcesSchemaPlugin; import org.fao.geonet.kernel.schema.SchemaPlugin; +import org.fao.geonet.kernel.search.EsFilterBuilder; import static org.fao.geonet.kernel.search.EsFilterBuilder.buildPermissionsFilter; import org.fao.geonet.kernel.search.EsSearchManager; import org.fao.geonet.kernel.search.submission.DirectIndexSubmitter; @@ -539,6 +540,10 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, } } + // Restrict all related searches to records the current user is allowed to view. + // Computed once here and reused for every query (as getAssociated does). + String permissionsFilter = buildPermissionsFilter(context); + if(type == null || type.length == 0) { type = RelatedItemType.class.getEnumConstants(); @@ -558,7 +563,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, // Search for children of this record if (listOfTypes.isEmpty() || listOfTypes.contains(RelatedItemType.children)) { - relatedRecords.addContent(calculateResults("\"" + uuid + "\"", "children", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, "\"" + uuid + "\"", "children", from, to, null, portalFilter)); } // Get parent record from this record @@ -568,7 +573,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, if (!listOfUUIDs.isEmpty()) { // Collect local record info (taking into account privileges) String joinedUUIDs = "\"" + Joiner.on("\" or \"").join(listOfUUIDs) + "\""; - relatedRecords.addContent(calculateResults(joinedUUIDs, "parent", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, joinedUUIDs, "parent", from, to, null, portalFilter)); } else { relatedRecords.addContent(new Element("parent")); } @@ -581,7 +586,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, Set listOfUUIDs = schemaPlugin.getAssociatedParentUUIDs(md); if (!listOfUUIDs.isEmpty()) { String joinedUUIDs = "\"" + Joiner.on("\" or \"").join(listOfUUIDs) + "\""; - relatedRecords.addContent(calculateResults(joinedUUIDs, RelatedItemType.brothersAndSisters.value(), from, to, uuid, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, joinedUUIDs, RelatedItemType.brothersAndSisters.value(), from, to, uuid, portalFilter)); } } @@ -597,7 +602,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, String origin; // Search in the index to use the portal filter and verify the metadata is available for the portal - Element searchResult = search("\"" + resource.getUuid() + "\"", RelatedItemType.siblings.value(), from, to, null, false); + Element searchResult = search(permissionsFilter, "\"" + resource.getUuid() + "\"", RelatedItemType.siblings.value(), from, to, null, false); // If can't be find, skip the result. if (hasResult(searchResult)) { origin = RelatedItemOrigin.portal.name(); @@ -629,13 +634,13 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, // Search for records where an aggregate point to this record if (listOfTypes.isEmpty() || listOfTypes.contains(RelatedItemType.associated)) { - relatedRecords.addContent(calculateResults("\"" + uuid + "\"", "associated", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, "\"" + uuid + "\"", "associated", from, to, null, portalFilter)); } // Search for services if (listOfTypes.isEmpty() || listOfTypes.contains(RelatedItemType.services)) { - relatedRecords.addContent(calculateResults("\"" + uuid + "\"", "services", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, "\"" + uuid + "\"", "services", from, to, null, portalFilter)); } // Related record from uuiref attributes in metadata record @@ -651,7 +656,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, Set listOfUUIDs = schemaPlugin.getAssociatedDatasetUUIDs(md); if (listOfUUIDs != null && !listOfUUIDs.isEmpty()) { String joinedUUIDs = "\"" + Joiner.on("\" or \"").join(listOfUUIDs) + "\""; - relatedRecords.addContent(calculateResults(joinedUUIDs, "datasets", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, joinedUUIDs, "datasets", from, to, null, portalFilter)); } else { relatedRecords.addContent(new Element("datasets")); } @@ -664,7 +669,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, Set listOfUUIDs = schemaPlugin.getAssociatedSourceUUIDs(md); if (listOfUUIDs != null && !listOfUUIDs.isEmpty()) { String joinedUUIDs = "\"" + Joiner.on("\" or \"").join(listOfUUIDs) + "\""; - relatedRecords.addContent(calculateResults(joinedUUIDs, "sources", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, joinedUUIDs, "sources", from, to, null, portalFilter)); } else { relatedRecords.addContent(new Element("sources")); } @@ -680,7 +685,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, for (String fcat_uuid : listOfUUIDs) { String origin; // Search in the index to use the portal filter and verify the metadata is available for the portal - Element searchResult = search("\"" + fcat_uuid + "\"", RelatedItemType.fcats.value(), from, to, null, false); + Element searchResult = search(permissionsFilter, "\"" + fcat_uuid + "\"", RelatedItemType.fcats.value(), from, to, null, false); // If can't be find, skip the result. if (hasResult(searchResult)) { origin = RelatedItemOrigin.portal.name(); @@ -710,7 +715,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, if (listOfTypes.isEmpty() || listOfTypes.contains(RelatedItemType.hassources)) { // Return records where this record is a source dataset - relatedRecords.addContent(calculateResults("\"" + uuid + "\"", "hassources", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, "\"" + uuid + "\"", "hassources", from, to, null, portalFilter)); } // Relation table is preserved for backward compatibility but should not be used anymore. @@ -719,7 +724,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, // Related records could be feature catalogue defined in relation table relatedRecords.addContent(new Element("related").addContent(Get.getRelation(iId, "full", context))); // Or feature catalogue define in feature catalogue citation - relatedRecords.addContent(calculateResults("\"" + uuid + "\"", "hasfeaturecats", from, to, null, portalFilter)); + relatedRecords.addContent(calculateResults(permissionsFilter, "\"" + uuid + "\"", "hasfeaturecats", from, to, null, portalFilter)); } // XSL transformation is used on the metadata record to extract @@ -733,7 +738,7 @@ public static Element getRelated(ServiceContext context, int iId, String uuid, return relatedRecords; } - private static Element search(String uuidQueryValue, String type, String from, String to, + private static Element search(String permissionsFilter, String uuidQueryValue, String type, String from, String to, String exclude, boolean ignorePortalFilter) throws Exception { ApplicationContext applicationContext = ApplicationContextHolder.get(); EsSearchManager searchMan = applicationContext.getBean(EsSearchManager.class); @@ -750,22 +755,23 @@ private static Element search(String uuidQueryValue, String type, String from, S excludeQuery = String.format(" -uuid:%s", exclude); } + // Resolve the portal filter through EsFilterBuilder so quotes are escaped consistently + // with the main search (a raw filter would break the query_string it is embedded in). String portalFilter = null; if (!ignorePortalFilter) { - SourceRepository sourceRepository = ApplicationContextHolder.get().getBean(SourceRepository.class); NodeInfo node = ApplicationContextHolder.get().getBean(NodeInfo.class); - if (node != null && !NodeInfo.DEFAULT_NODE.equals(node.getId())) { - final Optional portal = sourceRepository.findById(node.getId()); - - if (portal.isPresent() && StringUtils.isNotEmpty(portal.get().getFilter())) { - portalFilter = portal.get().getFilter(); - } - } + portalFilter = EsFilterBuilder.buildPortalFilter(node); } + // Always restrict results to records the current user is allowed to view, + // combining the view-privilege filter with the portal filter when it applies. + String effectiveFilter = (ignorePortalFilter || StringUtils.isEmpty(portalFilter)) + ? permissionsFilter + : String.format("(%s) AND (%s)", permissionsFilter, portalFilter); + final SearchResponse result = searchMan.query( String.format("+%s:(%s)%s", RELATED_INDEX_FIELDS.get(type), uuidQueryValue, excludeQuery), - ignorePortalFilter ? null : portalFilter, + effectiveFilter, FIELDLIST_CORE, fromValue, (toValue - fromValue)); @@ -970,17 +976,17 @@ private static boolean hasResult(Element searchResponse) { * @return * @throws Exception */ - private static Element calculateResults(String uuidQueryValue, String type, String from, String to, + private static Element calculateResults(String permissionsFilter, String uuidQueryValue, String type, String from, String to, String exclude, String portalFilter) throws Exception { // Search related resources ignoring portal filter - Element results = search(uuidQueryValue, type, from, to, exclude, true); + Element results = search(permissionsFilter, uuidQueryValue, type, from, to, exclude, true); // Check if the portal has a filter if (StringUtils.isNotEmpty(portalFilter)) { // Search related resources with the portal filter - Element resultsForPortal = search(uuidQueryValue, type, from, to, exclude, false); + Element resultsForPortal = search(permissionsFilter, uuidQueryValue, type, from, to, exclude, false); // Build the set of uuids from portal results HashSet portalResultsUuids = new HashSet<>(); diff --git a/services/src/test/java/org/fao/geonet/api/records/RelatedApiIntegrationTest.java b/services/src/test/java/org/fao/geonet/api/records/RelatedApiIntegrationTest.java new file mode 100644 index 00000000000..a9192916952 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/records/RelatedApiIntegrationTest.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.api.records; + +import jeeves.server.context.ServiceContext; +import org.fao.geonet.domain.AbstractMetadata; +import org.fao.geonet.domain.OperationAllowed; +import org.fao.geonet.domain.ReservedGroup; +import org.fao.geonet.domain.ReservedOperation; +import org.fao.geonet.kernel.datamanager.IMetadataIndexer; +import org.fao.geonet.kernel.search.IndexingMode; +import org.fao.geonet.kernel.search.submission.DirectIndexSubmitter; +import org.fao.geonet.repository.OperationAllowedRepository; +import org.fao.geonet.services.AbstractServiceIntegrationTest; +import org.jdom.Element; +import org.jdom.Namespace; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that the related-records API applies the view-privilege filter, so a + * restricted record related to a public one is not disclosed to unauthorized users. + * + *

Requires a running Elasticsearch instance (see project CLAUDE.md): run with the + * {@code -Pit} profile via the failsafe goals.

+ */ +public class RelatedApiIntegrationTest extends AbstractServiceIntegrationTest { + + private static final Namespace GMD = Namespace.getNamespace("gmd", "http://www.isotc211.org/2005/gmd"); + private static final Namespace GCO = Namespace.getNamespace("gco", "http://www.isotc211.org/2005/gco"); + + @Autowired + private WebApplicationContext wac; + @Autowired + private IMetadataIndexer metadataIndexer; + @Autowired + private OperationAllowedRepository operationAllowedRepository; + + private ServiceContext context; + + // Public parent record. + private String publicUuid; + // Restricted child of the public record (no view privilege for anonymous users). + private String restrictedUuid; + + @Before + public void setUp() throws Exception { + this.context = createServiceContext(); + loginAsAdmin(context); + + // Public parent record, viewable by the "all" (anonymous) group. + AbstractMetadata parent = injectMetadataInDb(getSampleMetadataXml(), context); + publicUuid = parent.getUuid(); + grantView(parent.getId(), ReservedGroup.all.getId()); + + // Restricted record declaring the public record as its parent, so it surfaces + // as a "children" relation. No view privilege is granted to any group. + Element childXml = getSampleMetadataXml(); + Element characterSet = childXml.getChild("characterSet", GMD); + Element parentIdentifier = new Element("parentIdentifier", GMD) + .addContent(new Element("CharacterString", GCO).setText(publicUuid)); + childXml.addContent(childXml.indexOf(characterSet) + 1, parentIdentifier); + AbstractMetadata child = injectMetadataInDb(childXml, context); + restrictedUuid = child.getUuid(); + + metadataIndexer.indexMetadata(String.valueOf(parent.getId()), DirectIndexSubmitter.INSTANCE, IndexingMode.full); + metadataIndexer.indexMetadata(String.valueOf(child.getId()), DirectIndexSubmitter.INSTANCE, IndexingMode.full); + } + + private void grantView(int metadataId, int groupId) { + OperationAllowed op = new OperationAllowed(); + op.getId().setMetadataId(metadataId).setGroupId(groupId) + .setOperationId(ReservedOperation.view.getId()); + operationAllowedRepository.save(op); + } + + @Test + public void anonymousDoesNotSeeRestrictedRelatedRecord() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession session = loginAsAnonymous(); + + mockMvc.perform(get("/srv/api/records/" + publicUuid + "/related") + .param("type", "children") + .session(session) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(not(containsString(restrictedUuid)))); + } + + @Test + public void adminSeesRestrictedRelatedRecord() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession session = loginAsAdmin(); + + mockMvc.perform(get("/srv/api/records/" + publicUuid + "/related") + .param("type", "children") + .session(session) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(restrictedUuid))); + } +}