diff --git a/config b/config index 8aba8a06d..cf790f376 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 8aba8a06dd876afd1a446c0393f72190d796ad61 +Subproject commit cf790f3762ae493ada56ab3fb9a054bdcfff6567 diff --git a/src/apps/geoserver/wfs/src/main/java/org/geoserver/cloud/wfs/config/WfsSecurityOverridesAutoconfiguration.java b/src/apps/geoserver/wfs/src/main/java/org/geoserver/cloud/wfs/config/WfsSecurityOverridesAutoconfiguration.java deleted file mode 100644 index ca0b76ca1..000000000 --- a/src/apps/geoserver/wfs/src/main/java/org/geoserver/cloud/wfs/config/WfsSecurityOverridesAutoconfiguration.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the - * GPL 2.0 license, available at the root application directory. - */ -package org.geoserver.cloud.wfs.config; - -import lombok.extern.slf4j.Slf4j; - -import org.geoserver.cloud.autoconfigure.catalog.backend.core.GeoServerBackendAutoConfiguration; -import org.geoserver.cloud.wfs.security.NoopLayerGroupContainmentCache; -import org.geoserver.security.impl.LayerGroupContainmentCache; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; - -/** - * Runs before {@link GeoServerBackendAutoConfiguration} to provide a {@link - * NoopLayerGroupContainmentCache} before {@link CoreBackendConfiguration's} {@code - * layerGroupContainmentCache()} does. - * - * @since 1.8.2 - */ -@AutoConfiguration(before = GeoServerBackendAutoConfiguration.class) -@Slf4j(topic = "org.geoserver.cloud.wfs.config") -public class WfsSecurityOverridesAutoconfiguration { - - @Bean - LayerGroupContainmentCache layerGroupContainmentCache() { - log.info("wfs-service is using a no-op LayerGroupContainmentCache"); - return new NoopLayerGroupContainmentCache(); - } -} diff --git a/src/apps/geoserver/wfs/src/main/resources/META-INF/spring.factories b/src/apps/geoserver/wfs/src/main/resources/META-INF/spring.factories index bbcd8a12b..7d1b69979 100644 --- a/src/apps/geoserver/wfs/src/main/resources/META-INF/spring.factories +++ b/src/apps/geoserver/wfs/src/main/resources/META-INF/spring.factories @@ -1,3 +1,2 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.geoserver.cloud.wfs.config.WfsAutoConfiguration,\ -org.geoserver.cloud.wfs.config.WfsSecurityOverridesAutoconfiguration +org.geoserver.cloud.wfs.config.WfsAutoConfiguration diff --git a/src/apps/geoserver/wfs/src/test/java/org/geoserver/cloud/wfs/app/WfsApplicationTest.java b/src/apps/geoserver/wfs/src/test/java/org/geoserver/cloud/wfs/app/WfsApplicationTest.java index 60b2b9dfd..ac2c1166d 100644 --- a/src/apps/geoserver/wfs/src/test/java/org/geoserver/cloud/wfs/app/WfsApplicationTest.java +++ b/src/apps/geoserver/wfs/src/test/java/org/geoserver/cloud/wfs/app/WfsApplicationTest.java @@ -4,17 +4,11 @@ */ package org.geoserver.cloud.wfs.app; -import static org.assertj.core.api.Assertions.assertThat; - -import org.geoserver.cloud.wfs.security.NoopLayerGroupContainmentCache; -import org.geoserver.security.impl.LayerGroupContainmentCache; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.context.ApplicationContext; import org.xmlunit.assertj3.XmlAssert; import java.util.Map; @@ -24,8 +18,6 @@ abstract class WfsApplicationTest { protected TestRestTemplate restTemplate = new TestRestTemplate("admin", "geoserver"); - @Autowired protected ApplicationContext appContext; - @Test void owsGetCapabilitiesSmokeTest(@LocalServerPort int servicePort) { String url = @@ -49,10 +41,4 @@ void wfsGetCapabilitiesSmokeTest(@LocalServerPort int servicePort) { .withNamespaceContext(nscontext) .hasXPath("/wfs:WFS_Capabilities"); } - - @Test - void noopLayerGroupContainmentCache() { - var lgcc = appContext.getBean(LayerGroupContainmentCache.class); - assertThat(lgcc).isInstanceOf(NoopLayerGroupContainmentCache.class); - } } diff --git a/src/catalog/backends/common/src/main/java/org/geoserver/cloud/config/catalog/backend/core/CoreBackendConfiguration.java b/src/catalog/backends/common/src/main/java/org/geoserver/cloud/config/catalog/backend/core/CoreBackendConfiguration.java index 271c58683..5268cec6b 100644 --- a/src/catalog/backends/common/src/main/java/org/geoserver/cloud/config/catalog/backend/core/CoreBackendConfiguration.java +++ b/src/catalog/backends/common/src/main/java/org/geoserver/cloud/config/catalog/backend/core/CoreBackendConfiguration.java @@ -4,6 +4,8 @@ */ package org.geoserver.cloud.config.catalog.backend.core; +import lombok.extern.slf4j.Slf4j; + import org.geoserver.catalog.Catalog; import org.geoserver.catalog.LayerGroupVisibilityPolicy; import org.geoserver.catalog.impl.AdvertisedCatalog; @@ -23,20 +25,22 @@ import org.geoserver.security.SecureCatalogImpl; import org.geoserver.security.impl.DataAccessRuleDAO; import org.geoserver.security.impl.DefaultResourceAccessManager; +import org.geoserver.security.impl.GsCloudLayerGroupContainmentCache; import org.geoserver.security.impl.LayerGroupContainmentCache; +import org.geoserver.security.impl.NoopLayerGroupContainmentCache; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; -import org.springframework.context.event.ApplicationContextEvent; -import org.springframework.context.event.ContextRefreshedEvent; // proxyBeanMethods = true required to avoid circular reference exceptions, especially related to // GeoServerExtensions still being created @Configuration(proxyBeanMethods = true) @EnableConfigurationProperties(CatalogProperties.class) +@Slf4j(topic = "org.geoserver.cloud.config.catalog.backend.core") public class CoreBackendConfiguration { @Bean @@ -111,27 +115,40 @@ DefaultResourceAccessManager defaultResourceAccessManager( // } /** - * Added to {@literal gs-main.jar} in 2.22.x as + * Actuial {@link LayerGroupContainmentCache}, matches if the config proeprty {@code + * geoserver.security.layergroup-containmentcache=true} * - *
- * {@code - *- * - * } - *- * - * Overridden here to act only upon {@link ContextRefreshedEvent} - * instead of on every {@link ApplicationContextEvent}, - * especially due to {@code org.springframework.cloud.client.discovery.event.HeartbeatEvent} and possibly - * others. - *
- * Update: as of geoserver 2.23.2, {@code LayerGroupContainmentCache} implements {@code ApplicationListener
} + * @see #noOpLayerGroupContainmentCache(Catalog) */ - @Bean - @ConditionalOnMissingBean - LayerGroupContainmentCache layerGroupContainmentCache( + @Bean(name = "layerGroupContainmentCache") + @ConditionalOnGeoServerSecurityEnabled + @ConditionalOnProperty( + name = "geoserver.security.layergroup-containmentcache", + havingValue = "true", + matchIfMissing = false) + LayerGroupContainmentCache enabledLayerGroupContainmentCache( @Qualifier("rawCatalog") Catalog rawCatalog) { - return new LayerGroupContainmentCache(rawCatalog); + + log.info("using {}", GsCloudLayerGroupContainmentCache.class.getSimpleName()); + return new GsCloudLayerGroupContainmentCache(rawCatalog); + } + + /** + * Default {@link LayerGroupContainmentCache} is a no-op, matches if the config proeprty {@code + * geoserver.security.layergroup-containmentcache=false} or is not specified + * + * @see #enabledLayerGroupContainmentCache(Catalog) + */ + @Bean(name = "layerGroupContainmentCache") + @ConditionalOnGeoServerSecurityEnabled + @ConditionalOnProperty( + name = "geoserver.security.layergroup-containmentcache", + havingValue = "false", + matchIfMissing = true) + LayerGroupContainmentCache noOpLayerGroupContainmentCache() { + + log.info("using {}", NoopLayerGroupContainmentCache.class.getSimpleName()); + return new NoopLayerGroupContainmentCache(); } @ConditionalOnGeoServerSecurityDisabled @@ -146,8 +163,7 @@ Catalog secureCatalogDisabled(@Qualifier("rawCatalog") Catalog rawCatalog) { */ @Bean Catalog advertisedCatalog( - @Qualifier("secureCatalog") Catalog secureCatalog, CatalogProperties properties) - throws Exception { + @Qualifier("secureCatalog") Catalog secureCatalog, CatalogProperties properties) { if (properties.isAdvertised()) { AdvertisedCatalog advertisedCatalog = new AdvertisedCatalog(secureCatalog); advertisedCatalog.setLayerGroupVisibilityPolicy(LayerGroupVisibilityPolicy.HIDE_NEVER); @@ -162,8 +178,8 @@ Catalog advertisedCatalog( */ @Bean(name = {"catalog", "localWorkspaceCatalog"}) Catalog localWorkspaceCatalog( - @Qualifier("advertisedCatalog") Catalog advertisedCatalog, CatalogProperties properties) - throws Exception { + @Qualifier("advertisedCatalog") Catalog advertisedCatalog, + CatalogProperties properties) { return properties.isLocalWorkspace() ? new LocalWorkspaceCatalog(advertisedCatalog) : advertisedCatalog; diff --git a/src/catalog/backends/common/src/main/java/org/geoserver/security/impl/GsCloudLayerGroupContainmentCache.java b/src/catalog/backends/common/src/main/java/org/geoserver/security/impl/GsCloudLayerGroupContainmentCache.java new file mode 100644 index 000000000..221e90741 --- /dev/null +++ b/src/catalog/backends/common/src/main/java/org/geoserver/security/impl/GsCloudLayerGroupContainmentCache.java @@ -0,0 +1,393 @@ +/* + * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.security.impl; + +import com.google.common.base.Stopwatch; + +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.catalog.Catalog; +import org.geoserver.catalog.CatalogException; +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.LayerGroupInfo.Mode; +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.PublishedInfo; +import org.geoserver.catalog.ResourceInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.event.CatalogAddEvent; +import org.geoserver.catalog.event.CatalogListener; +import org.geoserver.catalog.event.CatalogModifyEvent; +import org.geoserver.catalog.event.CatalogPostModifyEvent; +import org.geoserver.catalog.event.CatalogRemoveEvent; +import org.geoserver.catalog.impl.CatalogImpl; +import org.geoserver.catalog.impl.LayerGroupStyleListener; +import org.geotools.api.filter.Filter; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.web.context.WebApplicationContext; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Alternative to {@link LayerGroupContainmentCache} + * + * + * + *
+ *
+ * + * With this, it takes 1 mintue to build the cache with the pgconfig catalog backend, on a catalog + * with 70k layer groups, whereas previously it would go out of memory after several minutes. + * + *- Avoids building the cache twice during startup, once at the class constructor and again at + * {@link #onApplicationEvent(ContextRefreshedEvent)} + *
- {@link #onApplicationEvent(ContextRefreshedEvent)} ignores the event if it's not for a + * {@link WebApplicationContext} (e.g. the spring boot actuator's context) + *
- Makes a single pass over the {@link LayerGroupInfo}s in the catalog at {@link + * #buildLayerGroupCaches()} + *
- Traverses the layer groups in a streaming fashion, avoiding loading them all in memory + * through {@link Catalog#getLayerGroups()} + *
Further improvements may involve making the cache being build lazily as required by calls to + * {@link #getContainerGroupsFor(LayerGroupInfo)} and/or {@link + * #getContainerGroupsFor(ResourceInfo)}. For the later, only global and same-workspace layer groups + * may be queried. + * + * @see NoopLayerGroupContainmentCache + * @since 1.8.2 + */ +@Slf4j +@SuppressWarnings({"java:S2177", "java:S6201", "java:S3776", "java:S3398"}) +public class GsCloudLayerGroupContainmentCache extends LayerGroupContainmentCache + implements ApplicationContextAware { + + private Catalog catalog; + private ApplicationContext applicationContext; + + public GsCloudLayerGroupContainmentCache(Catalog rawCatalog) { + /* + * fix: constructor calls buildLayerGroupCaches, give it an empty catalog, + * we override everything here, and avoid building the cache twice, + * on the constructor and on the app context event + */ + super(new CatalogImpl()); + this.catalog = rawCatalog; + catalog.addListener(new CatalogChangeListener()); + catalog.addListener(new LayerGroupStyleListener()); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (event.getApplicationContext() != this.applicationContext) { + log.debug("Ignoring non web application context refresh event"); + return; + } + + log.info("Application context refreshed, building layer group containment cache"); + Stopwatch sw = Stopwatch.createStarted(); + buildLayerGroupCaches(); + sw.stop(); + log.info( + "Built layer group containment cache in {}. Group cache size: {}, resource containment cache size: {} with {} layergroups", + sw, + groupCache.size(), + resourceContainmentCache.size(), + resourceContainmentCache.values().stream().flatMap(Set::stream).count()); + } + + private void buildLayerGroupCaches() { + groupCache.clear(); + resourceContainmentCache.clear(); + + /* + * fix: make a single pass over the groups in a streaming way + */ + try (var groups = catalog.list(LayerGroupInfo.class, Filter.INCLUDE)) { + + while (groups.hasNext()) { + LayerGroupInfo group = groups.next(); + addGroupInfo(group); + registerContainedGroups(group); + } + } + } + + private void registerContainedGroups(LayerGroupInfo lg) { + lg.getLayers().stream() + .filter(IS_GROUP) + .map(LayerGroupInfo.class::cast) + .forEach( + p -> { + LayerGroupSummary container = getGroupData(lg); + LayerGroupSummary contained = getGroupData(p); + if (container != null && contained != null) { + contained.containerGroups.add(container); + } + }); + } + + private void addGroupInfo(LayerGroupInfo lg) { + LayerGroupSummary groupData = getGroupData(lg); + lg.getLayers().stream() + .filter(IS_LAYER) + .map(LayerInfo.class::cast) + .forEach( + p -> { + String id = p.getResource().getId(); + Set
containers = + resourceContainmentCache.computeIfAbsent( + id, CONCURRENT_SET_BUILDER); + containers.add(groupData); + }); + } + + /* + * fix: use computeIfAbsent, addGroupInfo and registerContainedGroups can hence be called in a single pass + */ + private LayerGroupSummary getGroupData(LayerGroupInfo lg) { + return groupCache.computeIfAbsent(lg.getId(), id -> new LayerGroupSummary(lg)); + } + + private void clearGroupInfo(LayerGroupInfo lg) { + LayerGroupSummary data = groupCache.remove(lg.getId()); + // clear the resource containment cache + lg.getLayers().stream() + .filter(IS_LAYER) + .forEach( + p -> { + String rid = ((LayerInfo) p).getResource().getId(); + synchronized (rid) { + Set containers = + resourceContainmentCache.get(rid); + if (containers != null) { + containers.remove(data); + } + } + }); + // this group does not contain anything anymore, remove from containment + for (LayerGroupSummary d : groupCache.values()) { + // will be removed by equality + d.containerGroups.remove(new LayerGroupSummary(lg)); + } + } + + /** Returns all groups containing directly or indirectly containing the resource */ + @Override + public Collection getContainerGroupsFor(ResourceInfo resource) { + String id = resource.getId(); + Set groups = resourceContainmentCache.get(id); + if (groups == null) { + return Collections.emptyList(); + } + Set result = new HashSet<>(); + for (LayerGroupSummary lg : groups) { + collectContainers(lg, result); + } + return result; + } + + /** + * Returns all groups containing directly or indirectly the specified group, and relevant for + * security (e.g., anything but {@link LayerGroupInfo.Mode#SINGLE} ones + */ + @Override + public Collection getContainerGroupsFor(LayerGroupInfo lg) { + String id = lg.getId(); + if (id == null) { + return Collections.emptyList(); + } + LayerGroupSummary summary = groupCache.get(id); + if (summary == null) { + return Collections.emptyList(); + } + + Set result = new HashSet<>(); + for (LayerGroupSummary container : summary.getContainerGroups()) { + collectContainers(container, result); + } + return result; + } + + /** + * Recursively collects the group and all its containers in the groups collection + */ + private void collectContainers(LayerGroupSummary lg, Set groups) { + if (!groups.contains(lg)) { + if (lg.getMode() != LayerGroupInfo.Mode.SINGLE) { + groups.add(lg); + } + for (LayerGroupSummary container : lg.containerGroups) { + collectContainers(container, groups); + } + } + } + + /** + * This listener keeps the "layer group" flags in the authorization tree current, in order to + * optimize the application of layer group containment rules + */ + final class CatalogChangeListener implements CatalogListener { + + @Override + public void handleAddEvent(CatalogAddEvent event) throws CatalogException { + if (event.getSource() instanceof LayerGroupInfo) { + LayerGroupInfo lg = (LayerGroupInfo) event.getSource(); + addGroupInfo(lg); + registerContainedGroups(lg); + } + } + + @Override + public void handleRemoveEvent(CatalogRemoveEvent event) throws CatalogException { + if (event.getSource() instanceof LayerGroupInfo) { + LayerGroupInfo lg = (LayerGroupInfo) event.getSource(); + clearGroupInfo(lg); + } + // no need to listen to workspace or layer removal, these will cascade to + // layer groups + } + + @Override + public void handleModifyEvent(CatalogModifyEvent event) throws CatalogException { + final CatalogInfo source = event.getSource(); + if (source instanceof LayerGroupInfo) { + LayerGroupInfo lg = (LayerGroupInfo) event.getSource(); + // was the layer group renamed, moved, or its contents changed? + int nameIdx = event.getPropertyNames().indexOf("name"); + if (nameIdx != -1) { + String newName = (String) event.getNewValues().get(nameIdx); + updateGroupName(lg.getId(), newName); + } + int wsIdx = event.getPropertyNames().indexOf("workspace"); + if (wsIdx != -1) { + WorkspaceInfo newWorkspace = (WorkspaceInfo) event.getNewValues().get(wsIdx); + updateGroupWorkspace(lg.getId(), newWorkspace); + } + int layerIdx = event.getPropertyNames().indexOf("layers"); + if (layerIdx != -1) { + @SuppressWarnings("unchecked") + List oldLayers = + (List ) event.getOldValues().get(layerIdx); + @SuppressWarnings("unchecked") + List newLayers = + (List ) event.getNewValues().get(layerIdx); + updateContainedLayers(groupCache.get(lg.getId()), oldLayers, newLayers); + } + int modeIdx = event.getPropertyNames().indexOf("mode"); + if (modeIdx != -1) { + Mode newMode = (Mode) event.getNewValues().get(modeIdx); + updateGroupMode(lg.getId(), newMode); + } + } else if (source instanceof WorkspaceInfo) { + int nameIdx = event.getPropertyNames().indexOf("name"); + if (nameIdx != -1) { + String oldName = (String) event.getOldValues().get(nameIdx); + String newName = (String) event.getNewValues().get(nameIdx); + updateWorkspaceNames(oldName, newName); + } + } + } + + private void updateGroupMode(String id, Mode newMode) { + LayerGroupSummary summary = groupCache.get(id); + summary.mode = newMode; + } + + private void updateContainedLayers( + LayerGroupSummary groupSummary, + List oldLayers, + List newLayers) { + + // process layers that are no more contained + final HashSet removedLayers = new HashSet<>(oldLayers); + removedLayers.removeAll(newLayers); + for (PublishedInfo removed : removedLayers) { + if (removed instanceof LayerInfo) { + String resourceId = ((LayerInfo) removed).getResource().getId(); + Set containers = resourceContainmentCache.get(resourceId); + if (containers != null) { + synchronized (resourceId) { + containers.remove(groupSummary); + if (containers.isEmpty()) { + resourceContainmentCache.remove(resourceId, containers); + } + } + } + } else { + LayerGroupInfo child = (LayerGroupInfo) removed; + LayerGroupSummary summary = groupCache.get(child.getId()); + if (summary != null) { + summary.containerGroups.remove(groupSummary); + } + } + } + + // add the layers that are newly contained + final HashSet addedLayers = new HashSet<>(newLayers); + addedLayers.removeAll(oldLayers); + for (PublishedInfo added : addedLayers) { + if (added instanceof LayerInfo) { + String resourceId = ((LayerInfo) added).getResource().getId(); + synchronized (resourceId) { + Set containers = + resourceContainmentCache.computeIfAbsent( + resourceId, CONCURRENT_SET_BUILDER); + containers.add(groupSummary); + } + } else { + LayerGroupInfo child = (LayerGroupInfo) added; + LayerGroupSummary summary = groupCache.get(child.getId()); + if (summary != null) { + summary.containerGroups.add(groupSummary); + } + } + } + } + + private void updateGroupWorkspace(String id, WorkspaceInfo newWorkspace) { + LayerGroupSummary summary = groupCache.get(id); + if (summary != null) { + summary.workspace = newWorkspace == null ? null : newWorkspace.getName(); + } + } + + private void updateGroupName(String id, String newName) { + LayerGroupSummary summary = groupCache.get(id); + if (summary != null) { + summary.name = newName; + } + } + + private void updateWorkspaceNames(String oldName, String newName) { + groupCache.values().stream() + .filter(lg -> Objects.equals(lg.workspace, oldName)) + .forEach(lg -> lg.workspace = newName); + } + + @Override + public void handlePostModifyEvent(CatalogPostModifyEvent event) throws CatalogException { + // nothing to do here + + } + + @Override + public void reloaded() { + // rebuild the containment cache + buildLayerGroupCaches(); + } + } +} diff --git a/src/apps/geoserver/wfs/src/main/java/org/geoserver/cloud/wfs/security/NoopLayerGroupContainmentCache.java b/src/catalog/backends/common/src/main/java/org/geoserver/security/impl/NoopLayerGroupContainmentCache.java similarity index 78% rename from src/apps/geoserver/wfs/src/main/java/org/geoserver/cloud/wfs/security/NoopLayerGroupContainmentCache.java rename to src/catalog/backends/common/src/main/java/org/geoserver/security/impl/NoopLayerGroupContainmentCache.java index edec98b6f..865bf6e96 100644 --- a/src/apps/geoserver/wfs/src/main/java/org/geoserver/cloud/wfs/security/NoopLayerGroupContainmentCache.java +++ b/src/catalog/backends/common/src/main/java/org/geoserver/security/impl/NoopLayerGroupContainmentCache.java @@ -2,14 +2,13 @@ * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the * GPL 2.0 license, available at the root application directory. */ -package org.geoserver.cloud.wfs.security; +package org.geoserver.security.impl; import org.geoserver.catalog.LayerGroupInfo; import org.geoserver.catalog.impl.CatalogImpl; -import org.geoserver.security.impl.LayerGroupContainmentCache; /** - * A no-op {@link LayerGroupContainmentCache}, since the WFS service does not deal with {@link + * A no-op {@link LayerGroupContainmentCache}, since some services like WFS do not deal with {@link * LayerGroupInfo layer groups} at all, then avoid the starup overhead. * * @since 1.8.2 diff --git a/src/catalog/backends/common/src/test/java/org/geoserver/security/impl/GsCloudLayerGroupContainmentCacheTest.java b/src/catalog/backends/common/src/test/java/org/geoserver/security/impl/GsCloudLayerGroupContainmentCacheTest.java new file mode 100644 index 000000000..0acb96e49 --- /dev/null +++ b/src/catalog/backends/common/src/test/java/org/geoserver/security/impl/GsCloudLayerGroupContainmentCacheTest.java @@ -0,0 +1,443 @@ +/* + * (c) 2020 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.security.impl; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.geoserver.catalog.CascadeDeleteVisitor; +import org.geoserver.catalog.Catalog; +import org.geoserver.catalog.CatalogBuilder; +import org.geoserver.catalog.DataStoreInfo; +import org.geoserver.catalog.FeatureTypeInfo; +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.LayerGroupInfo.Mode; +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.NamespaceInfo; +import org.geoserver.catalog.PublishedInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geoserver.catalog.impl.CatalogImpl; +import org.geoserver.catalog.impl.NamespaceInfoImpl; +import org.geoserver.catalog.impl.WorkspaceInfoImpl; +import org.geoserver.data.test.MockData; +import org.geoserver.platform.GeoServerExtensionsHelper; +import org.geoserver.platform.GeoServerResourceLoader; +import org.geoserver.security.impl.LayerGroupContainmentCache.LayerGroupSummary; +import org.geotools.api.feature.type.Name; +import org.geotools.data.property.PropertyDataStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.web.context.WebApplicationContext; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.xml.namespace.QName; + +/** + * Tests {@link LayerGroupContainmentCache} udpates in face of catalog setup and changes + * + * copied and adapted from {@link LayerGroupContainmentCacheTest} + */ +class GsCloudLayerGroupContainmentCacheTest { + + private static final String WS = "ws"; + + private static final String ANOTHER_WS = "anotherWs"; + + private static final String NATURE_GROUP = "nature"; + + private static final String CONTAINER_GROUP = "containerGroup"; + + private GsCloudLayerGroupContainmentCache cc; + + private LayerGroupInfo nature; + + private LayerGroupInfo container; + + private static Catalog catalog; + + @TempDir static Path tmpDir; + + @BeforeAll + public static void setupBaseCatalog() throws Exception { + GeoServerExtensionsHelper.setIsSpringContext(false); + catalog = new CatalogImpl(); + catalog.setResourceLoader(new GeoServerResourceLoader()); + + // the workspace + addWorkspaceNamespace(WS); + addWorkspaceNamespace(ANOTHER_WS); + + // the builder + CatalogBuilder cb = new CatalogBuilder(catalog); + final WorkspaceInfo defaultWorkspace = catalog.getDefaultWorkspace(); + cb.setWorkspace(defaultWorkspace); + + // setup the store + String nsURI = catalog.getDefaultNamespace().getURI(); + + var resolver = new PathMatchingResourcePatternResolver(MockData.class.getClassLoader()); + Resource[] resources = + resolver.getResources("classpath:org/geoserver/data/test/*.properties"); + List
propFiles = Stream.of(resources).map(Resource::getFilename).toList(); + propFiles.stream() + .filter(f -> !f.equals("MarsPoi.properties")) // crs 49900 unknown + .forEach(GsCloudLayerGroupContainmentCacheTest::unpackTestData); + + File testData = tmpDir.toFile(); + DataStoreInfo storeInfo = cb.buildDataStore("store"); + storeInfo.getConnectionParameters().put("directory", testData); + storeInfo.getConnectionParameters().put("namespace", nsURI); + catalog.save(storeInfo); + + // setup all the layers + PropertyDataStore store = new PropertyDataStore(testData); + store.setNamespaceURI(nsURI); + cb.setStore(catalog.getDefaultDataStore(defaultWorkspace)); + for (Name name : store.getNames()) { + FeatureTypeInfo ft = cb.buildFeatureType(name); + cb.setupBounds(ft); + catalog.add(ft); + LayerInfo layer = cb.buildLayer(ft); + catalog.add(layer); + } + } + + private static void unpackTestData(String propsfileName) { + Path target = tmpDir.resolve(propsfileName); + try (InputStream in = MockData.class.getResourceAsStream(propsfileName)) { + assertNotNull(in); + Files.copy(in, target); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static void addWorkspaceNamespace(String wsName) { + WorkspaceInfoImpl ws = new WorkspaceInfoImpl(); + ws.setName(wsName); + catalog.add(ws); + NamespaceInfo ns = new NamespaceInfoImpl(); + ns.setPrefix(wsName); + ns.setURI("http://www.geoserver.org/" + wsName); + catalog.add(ns); + } + + @BeforeEach + public void setupLayerGrups() throws Exception { + LayerInfo lakes = catalog.getLayerByName(getLayerId(MockData.LAKES)); + LayerInfo forests = catalog.getLayerByName(getLayerId(MockData.FORESTS)); + LayerInfo roads = catalog.getLayerByName(getLayerId(MockData.ROAD_SEGMENTS)); + WorkspaceInfo ws = catalog.getDefaultWorkspace(); + + this.nature = addLayerGroup(NATURE_GROUP, Mode.SINGLE, ws, lakes, forests); + this.container = addLayerGroup(CONTAINER_GROUP, Mode.CONTAINER, null, nature, roads); + + cc = new GsCloudLayerGroupContainmentCache(catalog); + + /* + * Call onApplicationEvent(ContextRefreshedEvent), this version of LayerGroupContainmentCache + * does not create the cache twice, both at the constructor and on context refresh + */ + ContextRefreshedEvent contextRefreshedEvent = mock(ContextRefreshedEvent.class); + WebApplicationContext context = mock(WebApplicationContext.class); + when(contextRefreshedEvent.getApplicationContext()).thenReturn(context); + cc.setApplicationContext(context); + cc.onApplicationEvent(contextRefreshedEvent); + } + + @AfterEach + public void clearLayerGroups() throws Exception { + CascadeDeleteVisitor remover = new CascadeDeleteVisitor(catalog); + for (LayerGroupInfo lg : catalog.getLayerGroups()) { + if (catalog.getLayerGroup(lg.getId()) != null) { + remover.visit(lg); + } + } + } + + private LayerGroupInfo addLayerGroup( + String name, Mode mode, WorkspaceInfo ws, PublishedInfo... layers) throws Exception { + CatalogBuilder cb = new CatalogBuilder(catalog); + + LayerGroupInfo group = catalog.getFactory().createLayerGroup(); + group.setName(name); + group.setMode(mode); + if (ws != null) { + group.setWorkspace(ws); + } + if (layers != null) { + for (PublishedInfo layer : layers) { + group.getLayers().add(layer); + group.getStyles().add(null); + } + } + cb.calculateLayerGroupBounds(group); + catalog.add(group); + if (ws != null) { + return catalog.getLayerGroupByName(ws.getName(), name); + } else { + return catalog.getLayerGroupByName(name); + } + } + + private Set set(String... names) { + if (names == null) { + return Collections.emptySet(); + } + return new HashSet<>(Arrays.asList(names)); + } + + private Set containerNamesForGroup(LayerGroupInfo lg) { + Collection summaries = cc.getContainerGroupsFor(lg); + return summaries.stream().map(gs -> gs.prefixedName()).collect(Collectors.toSet()); + } + + private Set containerNamesForResource(QName name) { + Collection summaries = cc.getContainerGroupsFor(getResource(name)); + return summaries.stream().map(gs -> gs.prefixedName()).collect(Collectors.toSet()); + } + + private FeatureTypeInfo getResource(QName name) { + return catalog.getResourceByName(getLayerId(name), FeatureTypeInfo.class); + } + + private String getLayerId(QName name) { + return "ws:" + name.getLocalPart(); + } + + @Test + void buildLayerGroupCaches() { + GsCloudLayerGroupContainmentCache layerGroupContainmentCache = + new GsCloudLayerGroupContainmentCache(catalog); + ContextRefreshedEvent contextRefreshedEvent = mock(ContextRefreshedEvent.class); + WebApplicationContext context = mock(WebApplicationContext.class); + when(contextRefreshedEvent.getApplicationContext()).thenReturn(context); + + layerGroupContainmentCache.setApplicationContext(context); + layerGroupContainmentCache.onApplicationEvent(contextRefreshedEvent); + + assertEquals(2, layerGroupContainmentCache.groupCache.size()); + } + + @Test + void testInitialSetup() throws Exception { + // nature + Collection natureContainers = cc.getContainerGroupsFor(nature); + assertEquals(1, natureContainers.size()); + assertThat(natureContainers, contains(new LayerGroupSummary(container))); + LayerGroupSummary summary = natureContainers.iterator().next(); + assertNull(summary.getWorkspace()); + assertEquals(CONTAINER_GROUP, summary.getName()); + assertThat(summary.getContainerGroups(), empty()); + + // container has no contaning groups + assertThat(cc.getContainerGroupsFor(container), empty()); + + // now check the groups containing the layers (nature being SINGLE, not a container) + assertThat(containerNamesForResource(MockData.LAKES), equalTo(set(CONTAINER_GROUP))); + assertThat(containerNamesForResource(MockData.FORESTS), equalTo(set(CONTAINER_GROUP))); + assertThat( + containerNamesForResource(MockData.ROAD_SEGMENTS), equalTo(set(CONTAINER_GROUP))); + } + + @Test + void testAddLayerToNature() throws Exception { + LayerInfo neatline = catalog.getLayerByName(getLayerId(MockData.MAP_NEATLINE)); + nature.getLayers().add(neatline); + nature.getStyles().add(null); + catalog.save(nature); + + assertThat(containerNamesForResource(MockData.MAP_NEATLINE), equalTo(set(CONTAINER_GROUP))); + } + + @Test + void testAddLayerToContainer() throws Exception { + LayerInfo neatline = catalog.getLayerByName(getLayerId(MockData.MAP_NEATLINE)); + container.getLayers().add(neatline); + container.getStyles().add(null); + catalog.save(container); + + assertThat(containerNamesForResource(MockData.MAP_NEATLINE), equalTo(set(CONTAINER_GROUP))); + } + + @Test + void testRemoveLayerFromNature() throws Exception { + LayerInfo lakes = catalog.getLayerByName(getLayerId(MockData.LAKES)); + nature.getLayers().remove(lakes); + nature.getStyles().remove(0); + catalog.save(nature); + + assertThat(containerNamesForResource(MockData.LAKES), empty()); + assertThat(containerNamesForResource(MockData.FORESTS), equalTo(set(CONTAINER_GROUP))); + assertThat( + containerNamesForResource(MockData.ROAD_SEGMENTS), equalTo(set(CONTAINER_GROUP))); + } + + @Test + void testRemoveLayerFromContainer() throws Exception { + LayerInfo roads = catalog.getLayerByName(getLayerId(MockData.ROAD_SEGMENTS)); + container.getLayers().remove(roads); + container.getStyles().remove(0); + catalog.save(container); + + assertThat(containerNamesForResource(MockData.LAKES), equalTo(set(CONTAINER_GROUP))); + assertThat(containerNamesForResource(MockData.FORESTS), equalTo(set(CONTAINER_GROUP))); + assertThat(containerNamesForResource(MockData.ROAD_SEGMENTS), empty()); + } + + @Test + void testRemoveNatureFromContainer() throws Exception { + container.getLayers().remove(nature); + container.getStyles().remove(0); + catalog.save(container); + + assertThat(containerNamesForGroup(nature), empty()); + assertThat(containerNamesForResource(MockData.LAKES), empty()); + assertThat(containerNamesForResource(MockData.FORESTS), empty()); + assertThat( + containerNamesForResource(MockData.ROAD_SEGMENTS), equalTo(set(CONTAINER_GROUP))); + } + + @Test + void testRemoveAllGrups() throws Exception { + catalog.remove(container); + catalog.remove(nature); + + assertThat(containerNamesForGroup(nature), empty()); + assertThat(containerNamesForResource(MockData.LAKES), empty()); + assertThat(containerNamesForResource(MockData.FORESTS), empty()); + assertThat(containerNamesForResource(MockData.ROAD_SEGMENTS), empty()); + } + + @Test + void testAddRemoveNamed() throws Exception { + final String NAMED_GROUP = "named"; + LayerInfo neatline = catalog.getLayerByName(getLayerId(MockData.MAP_NEATLINE)); + LayerInfo lakes = catalog.getLayerByName(getLayerId(MockData.LAKES)); + + // add and check containment + LayerGroupInfo named = addLayerGroup(NAMED_GROUP, Mode.NAMED, null, lakes, neatline); + assertThat( + containerNamesForResource(MockData.LAKES), + equalTo(set(CONTAINER_GROUP, NAMED_GROUP))); + assertThat(containerNamesForResource(MockData.MAP_NEATLINE), equalTo(set(NAMED_GROUP))); + assertThat(containerNamesForGroup(named), empty()); + + // delete and check containment + catalog.remove(named); + assertThat(containerNamesForResource(MockData.LAKES), equalTo(set(CONTAINER_GROUP))); + assertThat(containerNamesForResource(MockData.MAP_NEATLINE), empty()); + assertThat(containerNamesForGroup(named), empty()); + } + + @Test + void testAddRemoveNestedNamed() throws Exception { + final String NESTED_NAMED = "nestedNamed"; + LayerInfo neatline = catalog.getLayerByName(getLayerId(MockData.MAP_NEATLINE)); + LayerInfo lakes = catalog.getLayerByName(getLayerId(MockData.LAKES)); + + // add, nest, and check containment + LayerGroupInfo nestedNamed = addLayerGroup(NESTED_NAMED, Mode.NAMED, null, lakes, neatline); + container.getLayers().add(nestedNamed); + container.getStyles().add(null); + catalog.save(container); + assertThat( + containerNamesForResource(MockData.LAKES), + equalTo(set(CONTAINER_GROUP, NESTED_NAMED))); + assertThat( + containerNamesForResource(MockData.MAP_NEATLINE), + equalTo(set(CONTAINER_GROUP, NESTED_NAMED))); + assertThat(containerNamesForGroup(nestedNamed), equalTo(set(CONTAINER_GROUP))); + + // delete and check containment + new CascadeDeleteVisitor(catalog).visit(nestedNamed); + assertThat(containerNamesForResource(MockData.LAKES), equalTo(set(CONTAINER_GROUP))); + assertThat(containerNamesForResource(MockData.MAP_NEATLINE), empty()); + assertThat(containerNamesForGroup(nestedNamed), empty()); + } + + @Test + void testRenameGroup() throws Exception { + nature.setName("renamed"); + catalog.save(nature); + + LayerGroupSummary summary = cc.groupCache.get(nature.getId()); + assertEquals("renamed", summary.getName()); + assertEquals(WS, summary.getWorkspace()); + } + + @Test + void testRenameWorkspace() throws Exception { + WorkspaceInfo ws = catalog.getDefaultWorkspace(); + ws.setName("renamed"); + try { + catalog.save(ws); + + LayerGroupSummary summary = cc.groupCache.get(nature.getId()); + assertEquals(NATURE_GROUP, summary.getName()); + assertEquals("renamed", summary.getWorkspace()); + } finally { + ws.setName(WS); + catalog.save(ws); + } + } + + @Test + void testChangeWorkspace() throws Exception { + DataStoreInfo store = catalog.getDataStores().get(0); + try { + WorkspaceInfo aws = catalog.getWorkspaceByName(ANOTHER_WS); + store.setWorkspace(aws); + catalog.save(store); + nature.setWorkspace(aws); + catalog.save(nature); + + LayerGroupSummary summary = cc.groupCache.get(nature.getId()); + assertEquals(NATURE_GROUP, summary.getName()); + assertEquals(ANOTHER_WS, summary.getWorkspace()); + } finally { + WorkspaceInfo ws = catalog.getWorkspaceByName(WS); + store.setWorkspace(ws); + catalog.save(store); + } + } + + @Test + void testChangeGroupMode() throws Exception { + LayerGroupSummary summary = cc.groupCache.get(nature.getId()); + assertEquals(Mode.SINGLE, summary.getMode()); + + nature.setMode(Mode.OPAQUE_CONTAINER); + catalog.save(nature); + + summary = cc.groupCache.get(nature.getId()); + assertEquals(Mode.OPAQUE_CONTAINER, summary.getMode()); + } +}