Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -539,6 +540,10 @@
}
}

// 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();
Expand All @@ -558,7 +563,7 @@
// 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
Expand All @@ -568,7 +573,7 @@
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));

Check failure on line 576 in services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "parent" 4 times.

See more on https://sonarcloud.io/project/issues?id=geonetwork_core-geonetwork&issues=AZ-EKlj9_LYQwJtWCb2V&open=AZ-EKlj9_LYQwJtWCb2V&pullRequest=9437
} else {
relatedRecords.addContent(new Element("parent"));
}
Expand All @@ -581,7 +586,7 @@
Set<String> 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));
}
}

Expand All @@ -597,7 +602,7 @@

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();
Expand Down Expand Up @@ -629,13 +634,13 @@
// 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
Expand All @@ -651,7 +656,7 @@
Set<String> 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));

Check failure on line 659 in services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "datasets" 4 times.

See more on https://sonarcloud.io/project/issues?id=geonetwork_core-geonetwork&issues=AZ-EKlj9_LYQwJtWCb2X&open=AZ-EKlj9_LYQwJtWCb2X&pullRequest=9437
} else {
relatedRecords.addContent(new Element("datasets"));
}
Expand All @@ -664,7 +669,7 @@
Set<String> 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));

Check failure on line 672 in services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "sources" 4 times.

See more on https://sonarcloud.io/project/issues?id=geonetwork_core-geonetwork&issues=AZ-EKlj9_LYQwJtWCb2W&open=AZ-EKlj9_LYQwJtWCb2W&pullRequest=9437
} else {
relatedRecords.addContent(new Element("sources"));
}
Expand All @@ -680,7 +685,7 @@
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();
Expand Down Expand Up @@ -710,7 +715,7 @@
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.
Expand All @@ -719,7 +724,7 @@
// 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
Expand All @@ -733,7 +738,7 @@
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);
Expand All @@ -750,22 +755,23 @@
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<Source> 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));

Expand Down Expand Up @@ -970,17 +976,17 @@
* @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,

Check failure on line 979 in services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=geonetwork_core-geonetwork&issues=AZ-EKlj9_LYQwJtWCb2Y&open=AZ-EKlj9_LYQwJtWCb2Y&pullRequest=9437
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<String> portalResultsUuids = new HashSet<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Requires a running Elasticsearch instance (see project CLAUDE.md): run with the
* {@code -Pit} profile via the failsafe goals.</p>
*/
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)));
}
}
Loading