From 389ffa595fb61369663cc7da1ba045b69e0b7e11 Mon Sep 17 00:00:00 2001 From: Gabriel Roldan Date: Tue, 12 Mar 2024 18:28:42 -0300 Subject: [PATCH] Authrorization response caching and spring cloud bus integration Implement a caching `AuthorizationService`, and integrate with the GeoServer plugin. Enabled by default through the `geoserver.acl.client.caching` boolean configuration property. Integrate server and client event-bus communication through `RuleEvent` and `AdminRuleEvent`, using the spring-cloud-bus and RabbitMQ as default binder. Cache eviction occurs upon rule events, evicting the cached authorization responses affected by the changed rules. Disabled by default, and enabled through the `geoserver.bus.enabled` boolean configuration property. The server configuration got the following new properties in `values.yml` (copied to /etc/geoserver/acl-service.yml in the Docker image): ``` geoserver.bus.enabled: false rabbitmq.host: rabbitmq rabbitmq.port: 5672 rabbitmq.user: guest rabbitmq.password: guest ``` The client plugin configuration got the following new properties: ``` geoserver.acl.client.caching: true geoserver.acl.client.startupCheck: true geoserver.acl.client.initTimeout: 10 ``` --- pom.xml | 10 + .../acl/authorization/AccessInfo.java | 24 +-- .../acl/authorization/AccessRequest.java | 21 +- .../ForwardingAuthorizationService.java | 21 ++ .../AuthorizationServiceImpl.java | 11 +- src/artifacts/api/pom.xml | 12 ++ .../bus/RabbitAutoConfiguration.java | 30 +++ .../main/resources/META-INF/spring.factories | 3 +- .../api/src/main/resources/application.yml | 123 +++++++----- .../api/src/main/resources/values.yml | 27 +++ .../acl/domain/adminrules/AdminRuleEvent.java | 7 +- .../geoserver/acl/domain/rules/RuleEvent.java | 12 +- .../client/config/ApiClientConfiguration.java | 62 +++++- .../client/config/ApiClientProperties.java | 9 + .../AuthorizationServiceClientAdaptor.java | 8 +- .../acl/client/AclClientAdaptor.java | 3 +- src/integration/spring-boot/pom.xml | 1 + .../spring-boot/spring-cloud-bus/pom.xml | 61 ++++++ .../AclSpringCloudBusAutoConfiguration.java | 38 ++++ .../bus/bridge/RemoteAclRuleEventsBridge.java | 82 ++++++++ .../acl/bus/bridge/RemoteAdminRuleEvent.java | 70 +++++++ .../acl/bus/bridge/RemoteRuleEvent.java | 73 +++++++ .../main/resources/META-INF/spring.factories | 3 + .../AclSpringCloudBusAutoConfigurationIT.java | 172 +++++++++++++++++ ...clSpringCloudBusAutoConfigurationTest.java | 56 ++++++ .../src/test/resources/application-it.yml | 34 ++++ .../src/test/resources/logback-test.xml | 15 ++ src/integration/spring/cache/pom.xml | 50 +++++ .../cache/CachingAuthorizationService.java | 128 +++++++++++++ ...hingAuthorizationServiceConfiguration.java | 68 +++++++ .../CachingAuthorizationServiceTest.java | 180 ++++++++++++++++++ src/integration/spring/domain/pom.xml | 5 + .../cache/CachingAuthorizationService.java | 145 -------------- src/integration/spring/pom.xml | 1 + .../ACLResourceAccessManager.java | 93 ++++----- .../plugin/accessmanager/wps/WPSHelper.java | 3 +- src/plugin/client/pom.xml | 4 + ...iClientAclDomainServicesConfiguration.java | 15 +- ...entAclDomainServicesConfigurationTest.java | 21 +- src/plugin/plugin/pom.xml | 8 + ...AuthorizationServiceAutoConfiguration.java | 36 ++++ .../main/resources/META-INF/spring.factories | 3 +- ...AclAccessManagerAutoConfigurationTest.java | 5 +- .../model/AccessRequestSimulatorModel.java | 5 +- 44 files changed, 1472 insertions(+), 286 deletions(-) create mode 100644 src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/ForwardingAuthorizationService.java create mode 100644 src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/bus/RabbitAutoConfiguration.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/pom.xml create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfiguration.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAclRuleEventsBridge.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAdminRuleEvent.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteRuleEvent.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/main/resources/META-INF/spring.factories create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationIT.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationTest.java create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/test/resources/application-it.yml create mode 100644 src/integration/spring-boot/spring-cloud-bus/src/test/resources/logback-test.xml create mode 100644 src/integration/spring/cache/pom.xml create mode 100644 src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java create mode 100644 src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java create mode 100644 src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java delete mode 100644 src/integration/spring/domain/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java create mode 100644 src/plugin/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/cache/CachingAuthorizationServiceAutoConfiguration.java diff --git a/pom.xml b/pom.xml index c3b4369..0b62be1 100644 --- a/pom.xml +++ b/pom.xml @@ -188,11 +188,21 @@ gs-acl-domain-spring-integration ${project.version} + + org.geoserver.acl.integration + gs-acl-cache + ${project.version} + org.geoserver.acl.integration spring-boot-simplejndi ${project.version} + + org.geoserver.acl.integration + gs-acl-spring-cloud-bus + ${project.version} + org.geoserver.acl.plugin diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessInfo.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessInfo.java index 2ab0860..753fca5 100644 --- a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessInfo.java +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessInfo.java @@ -58,17 +58,19 @@ public class AccessInfo { @Override public String toString() { - return String.format( - "AccessInfo[grant: %s, catalogMode: %s, area: %s, clip: %s, styles[def: %s, allowed: %s], cql[r: %s, w: %s], atts: %s", - grant, - catalogMode, - area == null ? "no" : "yes", - clipArea == null ? "no" : "yes", - defaultStyle, - allowedStyles == null || allowedStyles.isEmpty() ? "no" : allowedStyles.size(), - cqlFilterRead == null ? "no" : "yes", - cqlFilterWrite == null ? "no" : "yes", - attributes == null || attributes.isEmpty() ? "no" : attributes.size()); + StringBuilder sb = new StringBuilder("AccessInfo[grant: ").append(grant); + if (catalogMode != null) sb.append(", catalogMode: ").append(catalogMode); + if (area != null) sb.append(", area: yes"); + if (clipArea != null) sb.append(", clipArea: yes"); + if (defaultStyle != null) sb.append(", def style: ").append(defaultStyle); + if (null != allowedStyles && !allowedStyles.isEmpty()) + sb.append("allowed styles: ").append(allowedStyles); + if (cqlFilterRead != null) sb.append(", cql read filter: present"); + if (cqlFilterWrite != null) sb.append(", cql write filter: present"); + if (null != attributes && !attributes.isEmpty()) + sb.append(", attributes: ").append(attributes.size()); + sb.append(", matchingRules: ").append(matchingRules); + return sb.append("]").toString(); } public static class Builder { diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessRequest.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessRequest.java index afb1909..9c3c21f 100644 --- a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessRequest.java +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessRequest.java @@ -46,17 +46,16 @@ public AccessRequest validate() { } public @Override String toString() { - return String.format( - "%s[from:%s, by: %s(%s), for:%s:%s%s, layer:%s%s]", - getClass().getSimpleName(), - sourceAddress == null ? "" : sourceAddress, - user, - roles.stream().collect(Collectors.joining(", ")), - service, - request, - subfield == null ? "" : "(" + subfield + ")", - workspace == null ? "" : (workspace.isEmpty() ? "" : workspace + ":"), - layer); + StringBuilder sb = new StringBuilder("AccessRequest["); + sb.append("user: ").append(user == null ? "" : user); + sb.append(", roles: ").append(roles.stream().collect(Collectors.joining(","))); + if (null != sourceAddress) sb.append(", origin IP: ").append(sourceAddress); + if (null != service) sb.append(", service: ").append(service); + if (null != request) sb.append(", request: ").append(request); + if (null != subfield) sb.append(", subfield: ").append(subfield); + if (null != workspace) sb.append(", workspace: ").append(workspace); + if (null != layer) sb.append(", layer: ").append(layer); + return sb.append("]").toString(); } public static class Builder { diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/ForwardingAuthorizationService.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/ForwardingAuthorizationService.java new file mode 100644 index 0000000..8a6f49d --- /dev/null +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/ForwardingAuthorizationService.java @@ -0,0 +1,21 @@ +/* (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. + * + * Original from GeoFence 3.6 under GPL 2.0 license + */ +package org.geoserver.acl.authorization; + +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * @since 2.0 + */ +@RequiredArgsConstructor +public abstract class ForwardingAuthorizationService implements AuthorizationService { + + @NonNull @Delegate @Getter private final AuthorizationService delegate; +} diff --git a/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java b/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java index 10956ba..1b9eb65 100644 --- a/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java +++ b/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java @@ -20,6 +20,7 @@ import static org.geoserver.acl.domain.rules.SpatialFilterType.CLIP; import static org.geoserver.acl.domain.rules.SpatialFilterType.INTERSECT; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -63,7 +64,7 @@ * * @author Emanuele Tajariol (etj at geo-solutions.it) (originally as part of GeoFence) */ -@Slf4j +@Slf4j(topic = "org.geoserver.acl.authorization") @RequiredArgsConstructor public class AuthorizationServiceImpl implements AuthorizationService { @@ -75,7 +76,7 @@ public class AuthorizationServiceImpl implements AuthorizationService { * @return a plain List of the grouped matching Rules. */ @Override - public List getMatchingRules(AccessRequest request) { + public List getMatchingRules(@NonNull AccessRequest request) { request = request.validate(); Map> found = getMatchingRulesByRole(request); return flatten(found); @@ -90,7 +91,7 @@ private List flatten(Map> found) { } @Override - public AccessInfo getAccessInfo(AccessRequest request) { + public AccessInfo getAccessInfo(@NonNull AccessRequest request) { request = request.validate(); Map> groupedRules = getMatchingRulesByRole(request); @@ -107,12 +108,12 @@ public AccessInfo getAccessInfo(AccessRequest request) { List matchingIds = flatten(groupedRules).stream().map(Rule::getId).toList(); ret = ret.withMatchingRules(matchingIds); - log.debug("Request: {}, response: {}", ret, request); + log.debug("Request: {}, response: {}", request, ret); return ret; } @Override - public AdminAccessInfo getAdminAuthorization(AdminAccessRequest request) { + public AdminAccessInfo getAdminAuthorization(@NonNull AdminAccessRequest request) { Optional adminAuth = getAdminAuth(request); boolean adminRigths = isAdminAuth(adminAuth); String adminRuleId = adminAuth.map(AdminRule::getId).orElse(null); diff --git a/src/artifacts/api/pom.xml b/src/artifacts/api/pom.xml index 3bb9e5d..c21ed30 100644 --- a/src/artifacts/api/pom.xml +++ b/src/artifacts/api/pom.xml @@ -28,6 +28,18 @@ org.geoserver.acl.integration spring-boot-simplejndi + + org.geoserver.acl.integration + gs-acl-spring-cloud-bus + + + org.springframework.cloud + spring-cloud-bus + + + org.springframework.cloud + spring-cloud-starter-bus-amqp + org.geotools gt-main diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/bus/RabbitAutoConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/bus/RabbitAutoConfiguration.java new file mode 100644 index 0000000..7edbbd1 --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/bus/RabbitAutoConfiguration.java @@ -0,0 +1,30 @@ +/* (c) 2023 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.acl.autoconfigure.bus; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Import; + +import javax.annotation.PostConstruct; + +/** + * {@link org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration} is disabled in + * {@literal application.yml}; this auto configuration enables it when the {@literal + * geoserver.bus.enabled} configuration property is {@code true}. + */ +@AutoConfiguration +@ConditionalOnProperty(name = "geoserver.bus.enabled", havingValue = "true", matchIfMissing = false) +@Import(org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration.class) +@Slf4j +public class RabbitAutoConfiguration { + + @PostConstruct + void log() { + log.info("Loading RabbitMQ bus bridge"); + } +} diff --git a/src/artifacts/api/src/main/resources/META-INF/spring.factories b/src/artifacts/api/src/main/resources/META-INF/spring.factories index b7f54ff..4634ff2 100644 --- a/src/artifacts/api/src/main/resources/META-INF/spring.factories +++ b/src/artifacts/api/src/main/resources/META-INF/spring.factories @@ -10,5 +10,6 @@ org.geoserver.acl.autoconfigure.security.AclServiceSecurityAutoConfiguration,\ org.geoserver.acl.autoconfigure.security.InternalSecurityConfiguration,\ org.geoserver.acl.autoconfigure.security.PreAuthenticationSecurityAutoConfiguration,\ org.geoserver.acl.autoconfigure.security.AuthenticationManagerAutoConfiguration,\ -org.geoserver.acl.autoconfigure.springdoc.SpringDocAutoConfiguration +org.geoserver.acl.autoconfigure.springdoc.SpringDocAutoConfiguration,\ +org.geoserver.acl.autoconfigure.bus.RabbitAutoConfiguration diff --git a/src/artifacts/api/src/main/resources/application.yml b/src/artifacts/api/src/main/resources/application.yml index f619410..abb8d6e 100644 --- a/src/artifacts/api/src/main/resources/application.yml +++ b/src/artifacts/api/src/main/resources/application.yml @@ -1,3 +1,6 @@ +info: + component: Access Control List service + instance-id: ${spring.application.name}:${vcap.application.instance_id:${spring.application.instance_id:${spring.cloud.client.ip-address}}:${server.port}} # acl-service main application configuration # Do not edit this file. All configurable options are to be placed in the sibling values.yml, # provided on a separate file and defined through spring.config.additional-location:file:, @@ -58,15 +61,38 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - org.springframework.boot.autoconfigure.jdbc.JndiDataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration jpa: open-in-view: false + rabbitmq: + host: ${rabbitmq.host:rabbitmq} + port: ${rabbitmq.port:5672} + username: ${rabbitmq.user:guest} + password: ${rabbitmq.password:guest} + virtual-host: ${rabbitmq.vhost:} + cloud: + bus: + enabled: ${geoserver.bus.enabled} + id: ${info.instance-id} + trace.enabled: false #switch on tracing of acks (default off). + stream: + bindings: + # same bindings as for geoserver cloud + springCloudBusOutput: + destination: geoserver + springCloudBusInput: + destination: geoserver management: server: base-path: /acl port: 8081 endpoint: - health.probes.enabled: true + health: + probes: + enabled: true + show-components: when-authorized + show-details: when-authorized caches.enabled: true endpoints: web.exposure.include: ['*'] @@ -98,52 +124,54 @@ jndi: connection-timeout: ${pg.pool.connectionTimeout:3000} idle-timeout: ${pg.pool.idleTimeout:60000} -geoserver.acl: - datasource: - jndi-name: ${acl.db.jndiName:java:comp/env/jdbc/acl} - url: ${acl.db.url:} - username: ${acl.db.username:} - password: ${acl.db.password:} - hikari: - minimum-idle: ${acl.db.hikari.minimumIdle:1} - maximum-pool-size: ${acl.db.hikari.maximumPoolSize:20} - jpa: - show-sql: false - open-in-view: false - generate-ddl: false - properties: - hibernate: - format_sql: true - default_schema: ${pg.schema} - hbm2ddl.auto: ${acl.db.hbm2ddl.auto:validate} - dialect: ${acl.db.dialect:org.hibernate.spatial.dialect.postgis.PostgisPG10Dialect} - security: - headers: - enabled: ${acl.security.headers.enabled} - user-header: ${acl.security.headers.user-header} - roles-header: ${acl.security.headers.roles-header} - admin-roles: ${acl.security.headers.admin-roles} - internal: - enabled: ${acl.security.basic.enabled} - users: - admin: - admin: true - enabled: ${acl.users.admin.enabled} - password: "${acl.users.admin.password}" - # password is the bcrypt encoded value, for example, for pwd s3cr3t: - # password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" - geoserver: - # special user for GeoServer to ACL communication - # Using a `{noop}` default credentials for performance, since bcrypt adds a significant per-request overhead - # in the orther of 100ms. In production it should be replaced by a docker/k8s secret - admin: true - enabled: ${acl.users.geoserver.enabled} - password: "${acl.users.geoserver.password}" -# user: -# admin: false -# enabled: true -# # password is the bcrypt encoded value for s3cr3t -# password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" +geoserver: + bus.enabled: false + acl: + datasource: + jndi-name: ${acl.db.jndiName:java:comp/env/jdbc/acl} + url: ${acl.db.url:} + username: ${acl.db.username:} + password: ${acl.db.password:} + hikari: + minimum-idle: ${acl.db.hikari.minimumIdle:1} + maximum-pool-size: ${acl.db.hikari.maximumPoolSize:20} + jpa: + show-sql: false + open-in-view: false + generate-ddl: false + properties: + hibernate: + format_sql: true + default_schema: ${pg.schema} + hbm2ddl.auto: ${acl.db.hbm2ddl.auto:validate} + dialect: ${acl.db.dialect:org.hibernate.spatial.dialect.postgis.PostgisPG10Dialect} + security: + headers: + enabled: ${acl.security.headers.enabled} + user-header: ${acl.security.headers.user-header} + roles-header: ${acl.security.headers.roles-header} + admin-roles: ${acl.security.headers.admin-roles} + internal: + enabled: ${acl.security.basic.enabled} + users: + admin: + admin: true + enabled: ${acl.users.admin.enabled} + password: "${acl.users.admin.password}" + # password is the bcrypt encoded value, for example, for pwd s3cr3t: + # password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" + geoserver: + # special user for GeoServer to ACL communication + # Using a `{noop}` default credentials for performance, since bcrypt adds a significant per-request overhead + # in the orther of 100ms. In production it should be replaced by a docker/k8s secret + admin: true + enabled: ${acl.users.geoserver.enabled} + password: "${acl.users.geoserver.password}" +# user: +# admin: false +# enabled: true +# # password is the bcrypt encoded value for s3cr3t +# password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" --- spring.config.activate.on-profile: local @@ -208,3 +236,4 @@ geoserver: logging: level: root: error + org.geoserver.acl: info diff --git a/src/artifacts/api/src/main/resources/values.yml b/src/artifacts/api/src/main/resources/values.yml index efebf08..62a1c9b 100644 --- a/src/artifacts/api/src/main/resources/values.yml +++ b/src/artifacts/api/src/main/resources/values.yml @@ -1,3 +1,6 @@ +# +# ACL PostgreSQL database configuration +# pg.host: acldb pg.port: 5432 pg.db: acl @@ -9,6 +12,16 @@ pg.pool.max: 50 pg.pool.connectionTimeout: 3000 pg.pool.idleTimeout: 60000 +# +# Spring Cloud Bus integration with RabbitMQ +# +geoserver.bus.enabled: false +rabbitmq.host: rabbitmq +rabbitmq.port: 5672 +rabbitmq.user: guest +rabbitmq.password: guest +#rabbitmq.vhost: + # # Basic auth security configuration # @@ -37,3 +50,17 @@ acl.security.headers.user-header: sec-username acl.security.headers.roles-header: sec-roles acl.security.headers.admin-roles: ["ROLE_ADMINISTRATOR"] + +--- +spring.config.activate.on-profile: logging_debug +logging: + level: + root: info + org.geoserver.acl: debug + org.geoserver.acl.bus.bridge: debug + +--- +spring.config.activate.on-profile: logging_debug_events +logging: + org.geoserver.acl.bus.bridge: debug + diff --git a/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRuleEvent.java b/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRuleEvent.java index 813cd4a..e5922d3 100644 --- a/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRuleEvent.java +++ b/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRuleEvent.java @@ -4,12 +4,15 @@ */ package org.geoserver.acl.domain.adminrules; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; import lombok.NonNull; -import lombok.Value; import java.util.Set; -@Value +@Data +@AllArgsConstructor(access = AccessLevel.PROTECTED) public class AdminRuleEvent { public enum EventType { diff --git a/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/RuleEvent.java b/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/RuleEvent.java index 60d8c6a..8efeb75 100644 --- a/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/RuleEvent.java +++ b/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/RuleEvent.java @@ -4,14 +4,17 @@ */ package org.geoserver.acl.domain.rules; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; import lombok.NonNull; -import lombok.Value; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -@Value +@Data +@AllArgsConstructor(access = AccessLevel.PROTECTED) public class RuleEvent { public enum EventType { @@ -42,4 +45,9 @@ public static RuleEvent updated(@NonNull Set ids) { public static RuleEvent deleted(@NonNull String... ids) { return new RuleEvent(EventType.DELETED, Set.of(ids)); } + + @Override + public String toString() { + return "%s%s".formatted(eventType, ruleIds); + } } diff --git a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientConfiguration.java b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientConfiguration.java index 4840b84..deb79b0 100644 --- a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientConfiguration.java +++ b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientConfiguration.java @@ -4,29 +4,38 @@ */ package org.geoserver.acl.api.client.config; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.acl.api.client.DataRulesApi; import org.geoserver.acl.authorization.AuthorizationService; import org.geoserver.acl.client.AclClient; import org.geoserver.acl.client.AclClientAdaptor; import org.geoserver.acl.domain.adminrules.AdminRuleRepository; import org.geoserver.acl.domain.rules.RuleRepository; +import org.springframework.beans.factory.BeanInitializationException; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + /** * Include this configuration to contribute an {@link org.geoserver.acl.api.client.ApiClient} * * @since 1.0 */ @Configuration(proxyBeanMethods = false) +@Slf4j(topic = "org.geoserver.acl.api.client.config") public class ApiClientConfiguration { @Bean - AclClient aclClient(ApiClientProperties config) { + AclClient aclClient(ApiClientProperties config) throws InterruptedException { String basePath = config.getBasePath(); if (!StringUtils.hasText(basePath)) { - throw new IllegalStateException( + throw new BeanInitializationException( "Authorization service target URL not provided through config property geoserver.acl.client.basePath"); } @@ -38,10 +47,57 @@ AclClient aclClient(ApiClientProperties config) { client.setUsername(username); client.setPassword(password); client.setLogRequests(debugging); - + if (config.isStartupCheck()) { + waitForIt(client, config.getInitTimeout()); + } return client; } + private void waitForIt(AclClient client, int timeoutSeconds) throws InterruptedException { + RuntimeException error = null; + if (timeoutSeconds <= 0) { + error = connect(client); + } else { + final Instant end = Instant.now().plusSeconds(timeoutSeconds); + error = connect(client); + while (error != null && Instant.now().isBefore(end)) { + logWaiting(client, error); + TimeUnit.SECONDS.sleep(1); + error = connect(client); + } + } + if (error != null) { + String msg = + "Unable to connect to ACL after %,d seconds. URL: %s, user: %s, error: %s" + .formatted( + timeoutSeconds, + client.getBasePath(), + client.getUsername(), + error.getMessage()); + throw new BeanInitializationException(msg, error); + } + } + + private void logWaiting(AclClient client, RuntimeException e) { + String msg = + "ACL API endpoint not ready. URL: %s, user: %s, error: %s" + .formatted(client.getBasePath(), client.getUsername(), e.getMessage()); + log.info(msg); + } + + @Nullable + private RuntimeException connect(AclClient client) { + DataRulesApi rulesApi = client.getRulesApi(); + try { + Integer count = rulesApi.countAllRules(); + log.debug( + "Connected to ACL service at {}, rule count: {}", client.getBasePath(), count); + } catch (RuntimeException e) { + return e; + } + return null; + } + @Bean AclClientAdaptor aclClientAdaptor(AclClient client) { return new AclClientAdaptor(client); diff --git a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientProperties.java b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientProperties.java index 619cf39..8aaa20c 100644 --- a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientProperties.java +++ b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/config/ApiClientProperties.java @@ -13,4 +13,13 @@ public class ApiClientProperties { private String username; private String password; private boolean debug; + private boolean caching = true; + + /** whether to check the connection at startup */ + private boolean startupCheck = true; + + /** + * timeout in seconds for startup to fail if API is not available. Ignored if startupCheck=false + */ + private int initTimeout = 5; } diff --git a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java index 92e884a..56ec6dc 100644 --- a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java +++ b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java @@ -28,7 +28,8 @@ public class AuthorizationServiceClientAdaptor implements AuthorizationService { private final @NonNull RuleApiMapper ruleMapper = Mappers.ruleApiMapper(); @Override - public AccessInfo getAccessInfo(org.geoserver.acl.authorization.AccessRequest request) { + public AccessInfo getAccessInfo( + @NonNull org.geoserver.acl.authorization.AccessRequest request) { org.geoserver.acl.api.model.AccessRequest apiRequest; org.geoserver.acl.api.model.AccessInfo apiResponse; @@ -44,7 +45,7 @@ public AccessInfo getAccessInfo(org.geoserver.acl.authorization.AccessRequest re @Override public AdminAccessInfo getAdminAuthorization( - org.geoserver.acl.authorization.AdminAccessRequest request) { + @NonNull org.geoserver.acl.authorization.AdminAccessRequest request) { org.geoserver.acl.api.model.AdminAccessRequest apiRequest; org.geoserver.acl.api.model.AdminAccessInfo apiResponse; @@ -59,7 +60,8 @@ public AdminAccessInfo getAdminAuthorization( } @Override - public List getMatchingRules(org.geoserver.acl.authorization.AccessRequest request) { + public List getMatchingRules( + @NonNull org.geoserver.acl.authorization.AccessRequest request) { org.geoserver.acl.api.model.AccessRequest apiRequest; List apiResponse; diff --git a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/client/AclClientAdaptor.java b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/client/AclClientAdaptor.java index 0bef1a0..d88d789 100644 --- a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/client/AclClientAdaptor.java +++ b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/client/AclClientAdaptor.java @@ -6,6 +6,7 @@ import lombok.Getter; import lombok.NonNull; +import lombok.Setter; import org.geoserver.acl.api.client.integration.AdminRuleRepositoryClientAdaptor; import org.geoserver.acl.api.client.integration.AuthorizationServiceClientAdaptor; @@ -19,7 +20,7 @@ public class AclClientAdaptor { private final @NonNull @Getter RuleRepositoryClientAdaptor ruleRepository; private final @NonNull @Getter AdminRuleRepository adminRuleRepository; - private final @NonNull @Getter AuthorizationService authorizationService; + private @NonNull @Getter @Setter AuthorizationService authorizationService; public AclClientAdaptor(@NonNull AclClient client) { this.client = client; diff --git a/src/integration/spring-boot/pom.xml b/src/integration/spring-boot/pom.xml index 5a88d2f..2b02a91 100644 --- a/src/integration/spring-boot/pom.xml +++ b/src/integration/spring-boot/pom.xml @@ -16,5 +16,6 @@ pom spring-boot-simplejndi + spring-cloud-bus diff --git a/src/integration/spring-boot/spring-cloud-bus/pom.xml b/src/integration/spring-boot/spring-cloud-bus/pom.xml new file mode 100644 index 0000000..2212f0d --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + org.geoserver.acl.integration + spring-boot-integration + ${revision} + + gs-acl-spring-cloud-bus + jar + + + org.springframework.cloud + spring-cloud-bus + provided + + + org.springframework.cloud + spring-cloud-starter-bus-amqp + provided + + + org.geoserver.acl.domain + gs-acl-accessrules + + + org.geoserver.acl.domain + gs-acl-adminrules + + + org.projectlombok + lombok + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + rabbitmq + test + + + org.awaitility + awaitility + test + + + diff --git a/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfiguration.java b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfiguration.java new file mode 100644 index 0000000..295fb6e --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfiguration.java @@ -0,0 +1,38 @@ +/* (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.acl.autoconfigure.bus; + +import org.geoserver.acl.bus.bridge.RemoteAclRuleEventsBridge; +import org.geoserver.acl.bus.bridge.RemoteRuleEvent; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.bus.BusAutoConfiguration; +import org.springframework.cloud.bus.ConditionalOnBusEnabled; +import org.springframework.cloud.bus.ServiceMatcher; +import org.springframework.cloud.bus.event.Destination; +import org.springframework.cloud.bus.jackson.RemoteApplicationEventScan; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; + +/** + * @since 2.0 + */ +@AutoConfiguration +@AutoConfigureAfter(BusAutoConfiguration.class) +@ConditionalOnBusEnabled +@ConditionalOnProperty(name = "geoserver.bus.enabled", havingValue = "true", matchIfMissing = false) +@RemoteApplicationEventScan(basePackageClasses = {RemoteRuleEvent.class}) +public class AclSpringCloudBusAutoConfiguration { + + @Bean + RemoteAclRuleEventsBridge remoteAclRuleEventsBridge( + ApplicationEventPublisher publisher, + ServiceMatcher serviceMatcher, + Destination.Factory destinationFactory) { + + return new RemoteAclRuleEventsBridge(publisher, serviceMatcher, destinationFactory); + } +} diff --git a/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAclRuleEventsBridge.java b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAclRuleEventsBridge.java new file mode 100644 index 0000000..38134eb --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAclRuleEventsBridge.java @@ -0,0 +1,82 @@ +/* (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.acl.bus.bridge; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.acl.domain.adminrules.AdminRuleEvent; +import org.geoserver.acl.domain.rules.RuleEvent; +import org.springframework.cloud.bus.ServiceMatcher; +import org.springframework.cloud.bus.event.Destination; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; + +/** + * @since 2.0 + */ +@RequiredArgsConstructor +@Slf4j(topic = "org.geoserver.acl.bus.bridge") +public class RemoteAclRuleEventsBridge { + /** Constant indicating a remote event is destined to all services */ + private static final String DESTINATION_ALL_SERVICES = "**"; + + private final @NonNull ApplicationEventPublisher publisher; + private final @NonNull ServiceMatcher serviceMatcher; + private final @NonNull Destination.Factory destinationFactory; + + private Destination destinationService() { + return destinationFactory.getDestination(DESTINATION_ALL_SERVICES); + } + + @EventListener(RuleEvent.class) + public void publishRemoteRuleEvent(RuleEvent event) { + if (isLocal(event)) { + String busId = serviceMatcher.getBusId(); + Destination destination = destinationService(); + var remote = RemoteRuleEvent.valueOf(this, busId, destination, event); + log.debug("RuleEvent produced on this instance, publishing {}", remote); + publisher.publishEvent(remote); + } + } + + @EventListener(RemoteRuleEvent.class) + public void publishLocalRuleEvent(RemoteRuleEvent remote) { + if (!serviceMatcher.isFromSelf(remote)) { + RuleEvent local = remote.toLocal(); + log.debug("Publishing RuleEvent from incoming {}", remote); + publisher.publishEvent(local); + } + } + + @EventListener(AdminRuleEvent.class) + public void publishRemoteAdminRuleEvent(AdminRuleEvent event) { + if (isLocal(event)) { + String busId = serviceMatcher.getBusId(); + Destination destination = destinationService(); + var remote = RemoteAdminRuleEvent.valueOf(this, busId, destination, event); + log.debug("AdminRuleEvent produced on this instance, publishing {}", remote); + publisher.publishEvent(remote); + } + } + + @EventListener(RemoteAdminRuleEvent.class) + public void publishLocalAdminRuleEvent(RemoteAdminRuleEvent remote) { + if (!serviceMatcher.isFromSelf(remote)) { + AdminRuleEvent local = remote.toLocal(); + log.debug("Publishing AdminRuleEvent from incoming {}", remote); + publisher.publishEvent(local); + } + } + + private boolean isLocal(AdminRuleEvent local) { + return !RemoteAdminRuleEvent.isRemote(local); + } + + private boolean isLocal(RuleEvent local) { + return !RemoteRuleEvent.isRemote(local); + } +} diff --git a/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAdminRuleEvent.java b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAdminRuleEvent.java new file mode 100644 index 0000000..4d07642 --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteAdminRuleEvent.java @@ -0,0 +1,70 @@ +/* (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.acl.bus.bridge; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.acl.domain.adminrules.AdminRuleEvent; +import org.geoserver.acl.domain.adminrules.AdminRuleEvent.EventType; +import org.springframework.cloud.bus.event.Destination; +import org.springframework.cloud.bus.event.RemoteApplicationEvent; +import org.springframework.core.style.ToStringCreator; + +import java.util.Set; + +/** + * @since 2.0 + */ +@SuppressWarnings("serial") +@EqualsAndHashCode(callSuper = true) +public class RemoteAdminRuleEvent extends RemoteApplicationEvent { + + @Getter private EventType eventType; + @Getter private Set ruleIds; + + protected RemoteAdminRuleEvent() { + // for serialization libraries like Jackson + } + + RemoteAdminRuleEvent( + Object source, String originService, Destination destination, AdminRuleEvent local) { + super(source, originService, destination); + this.eventType = local.getEventType(); + this.ruleIds = local.getRuleIds(); + } + + public static RemoteAdminRuleEvent valueOf( + Object source, String originService, Destination destination, AdminRuleEvent local) { + return new RemoteAdminRuleEvent(source, originService, destination, local); + } + + @NonNull + public AdminRuleEvent toLocal() { + return new LocalRemoteAdminRuleEvent(eventType, ruleIds); + } + + public static boolean isRemote(AdminRuleEvent local) { + return (local instanceof LocalRemoteAdminRuleEvent); + } + + private static class LocalRemoteAdminRuleEvent extends AdminRuleEvent { + public LocalRemoteAdminRuleEvent(EventType type, Set ids) { + super(type, ids); + } + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("eventType", eventType) + .append("ruleIds", ruleIds) + .append("id", getId()) + .append("originService", getOriginService()) + .append("destinationService", getDestinationService()) + .toString(); + } +} diff --git a/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteRuleEvent.java b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteRuleEvent.java new file mode 100644 index 0000000..31816d9 --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/main/java/org/geoserver/acl/bus/bridge/RemoteRuleEvent.java @@ -0,0 +1,73 @@ +/* (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.acl.bus.bridge; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; + +import org.geoserver.acl.domain.rules.RuleEvent; +import org.geoserver.acl.domain.rules.RuleEvent.EventType; +import org.springframework.cloud.bus.event.Destination; +import org.springframework.cloud.bus.event.RemoteApplicationEvent; +import org.springframework.core.style.ToStringCreator; + +import java.util.Set; + +/** + * @since 2.0 + */ +@SuppressWarnings("serial") +@EqualsAndHashCode(callSuper = true) +public class RemoteRuleEvent extends RemoteApplicationEvent { + + @Getter private EventType eventType; + @Getter private Set ruleIds; + + protected RemoteRuleEvent() { + // for serialization libraries like Jackson + } + + RemoteRuleEvent(Object source, String origin, Destination destination, RuleEvent local) { + super(source, origin, destination); + this.eventType = local.getEventType(); + this.ruleIds = local.getRuleIds(); + } + + public static RemoteRuleEvent valueOf( + @NonNull Object source, + @NonNull String originService, + @NonNull Destination destination, + @NonNull RuleEvent local) { + + return new RemoteRuleEvent(source, originService, destination, local); + } + + @NonNull + public RuleEvent toLocal() { + return new LocalRemoteRuleEvent(eventType, ruleIds); + } + + public static boolean isRemote(RuleEvent event) { + return (event instanceof LocalRemoteRuleEvent); + } + + private static class LocalRemoteRuleEvent extends RuleEvent { + public LocalRemoteRuleEvent(EventType type, Set ids) { + super(type, ids); + } + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("eventType", eventType) + .append("ruleIds", ruleIds) + .append("id", getId()) + .append("originService", getOriginService()) + .append("destinationService", getDestinationService()) + .toString(); + } +} diff --git a/src/integration/spring-boot/spring-cloud-bus/src/main/resources/META-INF/spring.factories b/src/integration/spring-boot/spring-cloud-bus/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..1846f81 --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.geoserver.acl.autoconfigure.bus.AclSpringCloudBusAutoConfiguration \ No newline at end of file diff --git a/src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationIT.java b/src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationIT.java new file mode 100644 index 0000000..800fa2f --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationIT.java @@ -0,0 +1,172 @@ +/* (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.acl.autoconfigure.bus; + +import static org.assertj.core.api.Assertions.assertThat; + +import lombok.extern.slf4j.Slf4j; + +import org.awaitility.Awaitility; +import org.geoserver.acl.bus.bridge.RemoteAdminRuleEvent; +import org.geoserver.acl.bus.bridge.RemoteRuleEvent; +import org.geoserver.acl.domain.adminrules.AdminRuleEvent; +import org.geoserver.acl.domain.rules.RuleEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * @see {@literal src/test/resources/application-it.yml} + */ +@Testcontainers +@Slf4j +class AclSpringCloudBusAutoConfigurationIT { + + @Container + private static final RabbitMQContainer rabbitMQContainer = + new RabbitMQContainer("rabbitmq:3.11-management"); + + @Configuration + @EnableAutoConfiguration + static class EventCapture { + + List ruleEvents = new ArrayList<>(); + List remoteRuleEvents = new ArrayList<>(); + + List adminRuleEvents = new ArrayList<>(); + List remoteAdminRuleEvents = new ArrayList<>(); + + void clear() { + ruleEvents.clear(); + remoteRuleEvents.clear(); + adminRuleEvents.clear(); + remoteAdminRuleEvents.clear(); + } + + @EventListener(RuleEvent.class) + void capture(RuleEvent event) { + ruleEvents.add(event); + } + + @EventListener(RemoteRuleEvent.class) + void capture(RemoteRuleEvent event) { + log.info("captured {}", event); + remoteRuleEvents.add(event); + } + + @EventListener(AdminRuleEvent.class) + void capture(AdminRuleEvent event) { + adminRuleEvents.add(event); + } + + @EventListener(RemoteAdminRuleEvent.class) + void capture(RemoteAdminRuleEvent event) { + remoteAdminRuleEvents.add(event); + } + } + + private ConfigurableApplicationContext app1Context; + private ConfigurableApplicationContext app2Context; + private EventCapture app1CapturedEvents; + private EventCapture app2CapturedEvents; + + @BeforeEach + void beforeEeach() { + app1Context = newApplicationContext("app:1"); + app1CapturedEvents = app1Context.getBean(EventCapture.class); + + app2Context = newApplicationContext("app:2"); + app2CapturedEvents = app2Context.getBean(EventCapture.class); + } + + @AfterEach + void afterEach() { + if (app1Context != null) { + app1Context.close(); + } + if (app2Context != null) { + app2Context.close(); + } + } + + @Test + void testRuleEvent() throws InterruptedException { + RuleEvent ruleEvent = RuleEvent.deleted("r1", "r2", "r3"); + // publish on app1 + app1Context.publishEvent(ruleEvent); + // capture on app2 + List app2Captured = app2CapturedEvents.remoteRuleEvents; + List app2Local = app2CapturedEvents.ruleEvents; + + Awaitility.await() + .atMost(Duration.ofSeconds(2)) + .untilAsserted(() -> assertThat(app2Captured).singleElement()); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(app2Local).isNotEmpty()); + + RemoteRuleEvent capturedConverted = app2Captured.get(0); + assertThat(capturedConverted.toLocal()).isEqualTo(ruleEvent); + + // RemoteAclRuleEventsBridge shall have re-published the remote event as a local event + RuleEvent publishedAsLocal = app2Local.get(0); + assertThat(publishedAsLocal).isEqualTo(ruleEvent); + } + + @Test + void testAdminRuleEvent() throws InterruptedException { + AdminRuleEvent adminEvent = AdminRuleEvent.deleted("a1", "a2", "a3"); + // publish on app1 + app1Context.publishEvent(adminEvent); + // capture on app2 + List app2Captured = app2CapturedEvents.remoteAdminRuleEvents; + List app2Local = app2CapturedEvents.adminRuleEvents; + + Awaitility.await() + .atMost(Duration.ofSeconds(2)) + .untilAsserted(() -> assertThat(app2Captured).singleElement()); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(app2Local).isNotEmpty()); + + RemoteAdminRuleEvent capturedConverted = app2Captured.get(0); + assertThat(capturedConverted.toLocal()).isEqualTo(adminEvent); + + // RemoteAclRuleEventsBridge shall have re-published the remote event as a local event + AdminRuleEvent publishedAsLocal = app2Local.get(0); + assertThat(publishedAsLocal).isEqualTo(adminEvent); + } + + private static ConfigurableApplicationContext newApplicationContext(String appName) { + String host = rabbitMQContainer.getHost(); + Integer amqpPort = rabbitMQContainer.getAmqpPort(); + log.info("#".repeat(100)); + log.info( + "Initializing application context {}, rabbit host: {}, port: {}", + appName, + host, + amqpPort); + SpringApplicationBuilder remoteAppBuilder = + new SpringApplicationBuilder(EventCapture.class) + .profiles("it") // also load config from application-it.yml + .properties( + "spring.rabbitmq.host=" + host, + "spring.rabbitmq.port=" + amqpPort, + "spring.cloud.bus.id=" + appName); + return remoteAppBuilder.run(); + } +} diff --git a/src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationTest.java b/src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationTest.java new file mode 100644 index 0000000..b6ad99e --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/test/java/org/geoserver/acl/autoconfigure/bus/AclSpringCloudBusAutoConfigurationTest.java @@ -0,0 +1,56 @@ +/* (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.acl.autoconfigure.bus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import org.geoserver.acl.bus.bridge.RemoteAclRuleEventsBridge; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.bus.BusAutoConfiguration; +import org.springframework.cloud.bus.BusBridge; +import org.springframework.cloud.bus.PathServiceMatcherAutoConfiguration; +import org.springframework.cloud.bus.event.Destination; +import org.springframework.cloud.bus.event.PathDestinationFactory; + +class AclSpringCloudBusAutoConfigurationTest { + + private Destination.Factory destinationFactory = new PathDestinationFactory(); + private BusBridge mockBusBridge = mock(BusBridge.class); + + private ApplicationContextRunner runner = + new ApplicationContextRunner() + .withBean(Destination.Factory.class, () -> destinationFactory) + .withBean(BusBridge.class, () -> mockBusBridge) + .withConfiguration( + AutoConfigurations.of( + BusAutoConfiguration.class, + PathServiceMatcherAutoConfiguration.class, + AclSpringCloudBusAutoConfiguration.class)); + + @Test + void testDisabledByDefault() { + runner.run( + context -> { + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(RemoteAclRuleEventsBridge.class); + }); + } + + @Test + void testEnabled() { + runner.withPropertyValues("geoserver.bus.enabled=true") + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(RemoteAclRuleEventsBridge.class); + }); + } +} diff --git a/src/integration/spring-boot/spring-cloud-bus/src/test/resources/application-it.yml b/src/integration/spring-boot/spring-cloud-bus/src/test/resources/application-it.yml new file mode 100644 index 0000000..71097a1 --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/test/resources/application-it.yml @@ -0,0 +1,34 @@ +geoserver.bus.enabled: true +server: + port: 0 +spring: + main.banner-mode: off + rabbitmq: +# host: localhost +# port: 5672 + username: guest + password: guest + virtual-host: + cloud: + bus: + enabled: ${geoserver.bus.enabled} +# id: app:1 + trace.enabled: false #switch on tracing of acks (default off). + stream: + bindings: + # same bindings as for geoserver cloud + springCloudBusOutput: + destination: geoserver + springCloudBusInput: + destination: geoserver + + autoconfigure.exclude: + - org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration + + +logging: + level: + root: warn + org.geoserver.acl.bus.bridge: debug + org.springframework.cloud.bus: info + org.springframework.amqp.rabbit: info diff --git a/src/integration/spring-boot/spring-cloud-bus/src/test/resources/logback-test.xml b/src/integration/spring-boot/spring-cloud-bus/src/test/resources/logback-test.xml new file mode 100644 index 0000000..92a70ed --- /dev/null +++ b/src/integration/spring-boot/spring-cloud-bus/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/src/integration/spring/cache/pom.xml b/src/integration/spring/cache/pom.xml new file mode 100644 index 0000000..a84fea0 --- /dev/null +++ b/src/integration/spring/cache/pom.xml @@ -0,0 +1,50 @@ + + + + 4.0.0 + + org.geoserver.acl.integration + spring-integration + ${revision} + + gs-acl-cache + + + org.geoserver.acl + gs-acl-authorization-api + + + com.github.ben-manes.caffeine + caffeine + + + org.springframework + spring-context + provided + + + org.springframework + spring-context-support + provided + + + org.projectlombok + lombok + + + org.slf4j + slf4j-api + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java new file mode 100644 index 0000000..45eff20 --- /dev/null +++ b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java @@ -0,0 +1,128 @@ +/* (c) 2023 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.acl.authorization.cache; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.acl.authorization.AccessInfo; +import org.geoserver.acl.authorization.AccessRequest; +import org.geoserver.acl.authorization.AdminAccessInfo; +import org.geoserver.acl.authorization.AdminAccessRequest; +import org.geoserver.acl.authorization.AuthorizationService; +import org.geoserver.acl.authorization.ForwardingAuthorizationService; +import org.geoserver.acl.domain.adminrules.AdminRuleEvent; +import org.geoserver.acl.domain.rules.RuleEvent; +import org.springframework.context.event.EventListener; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; + +@Slf4j(topic = "org.geoserver.acl.authorization.cache") +public class CachingAuthorizationService extends ForwardingAuthorizationService { + + private final ConcurrentMap ruleAccessCache; + private final ConcurrentMap adminRuleAccessCache; + + public CachingAuthorizationService( + @NonNull AuthorizationService delegate, + @NonNull ConcurrentMap dataAccessCache, + @NonNull ConcurrentMap adminAccessCache) { + super(delegate); + + this.ruleAccessCache = dataAccessCache; + this.adminRuleAccessCache = adminAccessCache; + } + + @Override + public AccessInfo getAccessInfo(@NonNull AccessRequest request) { + AccessInfo grant = ruleAccessCache.computeIfAbsent(request, this::load); + if (grant.getMatchingRules().isEmpty()) {} + + return grant; + } + + private AccessInfo load(AccessRequest request) { + return super.getAccessInfo(request); + } + + @Override + public AdminAccessInfo getAdminAuthorization(@NonNull AdminAccessRequest request) { + return adminRuleAccessCache.computeIfAbsent(request, this::load); + } + + private AdminAccessInfo load(AdminAccessRequest request) { + return super.getAdminAuthorization(request); + } + + @EventListener(RuleEvent.class) + public void onRuleEvent(RuleEvent event) { + switch (event.getEventType()) { + case DELETED, UPDATED: + evictRuleAccessCache(event); + break; + case CREATED: + default: + break; + } + } + + @EventListener(AdminRuleEvent.class) + public void onAdminRuleEvent(AdminRuleEvent event) { + switch (event.getEventType()) { + case DELETED, UPDATED: + evictAdminAccessCache(event); + break; + case CREATED: + default: + break; + } + } + + private void evictRuleAccessCache(RuleEvent event) { + final Set affectedRuleIds = event.getRuleIds(); + ruleAccessCache.entrySet().stream() + .parallel() + .filter(e -> matches(e.getValue(), affectedRuleIds)) + .forEach( + e -> { + AccessRequest req = e.getKey(); + AccessInfo grant = e.getValue(); + ruleAccessCache.remove(req); + logEvicted(event, req, grant); + }); + } + + private void evictAdminAccessCache(AdminRuleEvent event) { + final Set affectedRuleIds = event.getRuleIds(); + adminRuleAccessCache.entrySet().stream() + .parallel() + .filter(e -> matches(e.getValue(), affectedRuleIds)) + .forEach( + e -> { + AdminAccessRequest req = e.getKey(); + AdminAccessInfo grant = e.getValue(); + adminRuleAccessCache.remove(req); + logEvicted(event, req, grant); + }); + } + + private boolean matches(AccessInfo cached, final Set affectedRuleIds) { + List matchingRules = cached.getMatchingRules(); + return matchingRules.stream().anyMatch(affectedRuleIds::contains); + } + + private boolean matches(AdminAccessInfo cached, final Set affectedRuleIds) { + String matchingRuleId = cached.getMatchingAdminRule(); + return affectedRuleIds.contains(matchingRuleId); + } + + private void logEvicted(Object event, Object req, Object grant) { + if (log.isDebugEnabled()) { + log.debug("event: {}, evicted {} -> {}", event, req, grant); + } + } +} diff --git a/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java new file mode 100644 index 0000000..8e7ddfb --- /dev/null +++ b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java @@ -0,0 +1,68 @@ +/* (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.acl.authorization.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import org.geoserver.acl.authorization.AccessInfo; +import org.geoserver.acl.authorization.AccessRequest; +import org.geoserver.acl.authorization.AdminAccessInfo; +import org.geoserver.acl.authorization.AdminAccessRequest; +import org.geoserver.acl.authorization.AuthorizationService; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.util.concurrent.ConcurrentMap; + +/** + * @since 2.0 + */ +@Configuration +public class CachingAuthorizationServiceConfiguration { + + @Bean + @Primary + CachingAuthorizationService cachingAuthorizationService( + AuthorizationService delegate, + ConcurrentMap authorizationCache, + ConcurrentMap adminAuthorizationCache) { + + return new CachingAuthorizationService( + delegate, authorizationCache, adminAuthorizationCache); + } + + @Bean + ConcurrentMap aclAuthCache(CacheManager cacheManager) { + return getCache(cacheManager, "acl-data-grants"); + } + + @Bean + ConcurrentMap aclAdminAuthCache( + CacheManager cacheManager) { + return getCache(cacheManager, "acl-admin-grants"); + } + + private ConcurrentMap getCache(CacheManager cacheManager, String cacheName) { + if (cacheManager instanceof CaffeineCacheManager ccf) { + org.springframework.cache.Cache cache = ccf.getCache(cacheName); + if (cache != null) { + @SuppressWarnings("unchecked") + Cache caffeineCache = (Cache) cache.getNativeCache(); + return caffeineCache.asMap(); + } + } + + return newCache(); + } + + private ConcurrentMap newCache() { + Cache cache = Caffeine.newBuilder().softValues().build(); + return cache.asMap(); + } +} diff --git a/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java b/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java new file mode 100644 index 0000000..f83f47a --- /dev/null +++ b/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java @@ -0,0 +1,180 @@ +/* (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.acl.authorization.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.geoserver.acl.authorization.AccessInfo; +import org.geoserver.acl.authorization.AccessRequest; +import org.geoserver.acl.authorization.AdminAccessInfo; +import org.geoserver.acl.authorization.AdminAccessRequest; +import org.geoserver.acl.authorization.AuthorizationService; +import org.geoserver.acl.domain.adminrules.AdminRule; +import org.geoserver.acl.domain.adminrules.AdminRuleEvent; +import org.geoserver.acl.domain.rules.Rule; +import org.geoserver.acl.domain.rules.RuleEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; + +class CachingAuthorizationServiceTest { + + private CachingAuthorizationService caching; + private AuthorizationService delegate; + private ConcurrentMap dataAccessCache; + private ConcurrentMap adminAccessCache; + + @BeforeEach + void setUp() throws Exception { + delegate = mock(AuthorizationService.class); + dataAccessCache = new ConcurrentHashMap<>(); + adminAccessCache = new ConcurrentHashMap<>(); + caching = new CachingAuthorizationService(delegate, dataAccessCache, adminAccessCache); + } + + @Test + void testCachingAuthorizationService() { + var npe = NullPointerException.class; + assertThrows( + npe, + () -> new CachingAuthorizationService(null, dataAccessCache, adminAccessCache)); + assertThrows(npe, () -> new CachingAuthorizationService(delegate, null, adminAccessCache)); + assertThrows(npe, () -> new CachingAuthorizationService(delegate, dataAccessCache, null)); + } + + @Test + void testGetAccessInfo() { + AccessRequest req = AccessRequest.builder().roles("ROLE_AUTHENTICATED").build(); + AccessInfo expected = AccessInfo.DENY_ALL; + when(delegate.getAccessInfo(req)).thenReturn(expected); + + AccessInfo r1 = caching.getAccessInfo(req); + AccessInfo r2 = caching.getAccessInfo(req); + AccessInfo r3 = caching.getAccessInfo(req); + assertSame(expected, r1); + assertSame(r1, r2); + assertSame(r2, r3); + assertSame(expected, this.dataAccessCache.get(req)); + verify(delegate, times(1)).getAccessInfo(req); + } + + @Test + void testGetAdminAuthorization() { + AdminAccessRequest req = + AdminAccessRequest.builder().roles("ROLE_AUTHENTICATED").workspace("test").build(); + AdminAccessInfo expected = AdminAccessInfo.builder().admin(false).workspace("test").build(); + when(delegate.getAdminAuthorization(req)).thenReturn(expected); + + AdminAccessInfo r1 = caching.getAdminAuthorization(req); + AdminAccessInfo r2 = caching.getAdminAuthorization(req); + AdminAccessInfo r3 = caching.getAdminAuthorization(req); + assertSame(expected, r1); + assertSame(r1, r2); + assertSame(r2, r3); + assertSame(expected, this.adminAccessCache.get(req)); + verify(delegate, times(1)).getAdminAuthorization(req); + } + + @Test + void testOnRuleEvent() { + Rule rule1 = Rule.allow().withId("r1").withWorkspace("ws1").withLayer("l1"); + Rule rule2 = rule1.withId("r2").withLayer("l2"); + Rule rule3 = rule1.withId("r3").withLayer("l3"); + Rule rule4 = rule1.withId("r4").withLayer("l4"); + Rule rule5 = rule1.withId("r5").withLayer("l5"); + + AccessRequest req1 = req(rule1); + AccessRequest req2 = req(rule2); + AccessRequest req3 = req(rule3); + AccessRequest req4 = req(rule4); + AccessRequest req5 = req(rule5); + + grant(req1, rule1); + grant(req2, rule1, rule2); + grant(req3, rule1, rule2, rule3); + grant(req4, rule4, rule5); + grant(req5, rule5); + + // change to rule5 evicts req5 and req4, both have rule5.id int their matching rules + testOnRuleEvent(rule5, req5, req4); + + // change to rule1 evicts req1, req2, and req3, all of them have rule1.id in their matching + // rules + testOnRuleEvent(rule1, req1, req2, req3); + } + + @Test + void testOnAdminRuleEvent() { + var rule1 = AdminRule.admin().withId("r1").withWorkspace("ws1"); + var rule2 = rule1.withId("r2"); + var rule3 = rule1.withId("r3"); + + var req1 = AdminAccessRequest.builder().user("user1").workspace("ws1").build(); + var req2 = req1.withUser("user2"); + var req3 = req1.withUser("user3"); + + grant(req1, rule1); + grant(req2, rule2); + grant(req3, rule3); + + testOnAdminRuleEvent(rule1, req1); + testOnAdminRuleEvent(rule2, req2); + testOnAdminRuleEvent(rule3, req3); + } + + private void testOnAdminRuleEvent(AdminRule modified, AdminAccessRequest expectedEviction) { + assertThat(adminAccessCache.get(expectedEviction)).isNotNull(); + var event = AdminRuleEvent.updated(modified); + caching.onAdminRuleEvent(event); + assertThat(adminAccessCache.get(expectedEviction)).isNull(); + } + + private void testOnRuleEvent(Rule modified, AccessRequest... expectedEvictions) { + // pre-flight + for (AccessRequest req : expectedEvictions) { + AccessInfo grant = dataAccessCache.get(req); + assertThat(grant).isNotNull(); + assertThat(grant.getMatchingRules()).contains(modified.getId()); + } + + RuleEvent event = RuleEvent.updated(modified); + caching.onRuleEvent(event); + + for (AccessRequest req : expectedEvictions) { + assertThat(dataAccessCache).doesNotContainKey(req); + } + } + + private AccessInfo grant(AccessRequest req, Rule... matching) { + List ids = Stream.of(matching).map(Rule::getId).toList(); + AccessInfo grant = AccessInfo.ALLOW_ALL.withMatchingRules(ids); + this.dataAccessCache.put(req, grant); + return grant; + } + + private AdminAccessInfo grant(AdminAccessRequest req, AdminRule matching) { + AdminAccessInfo grant = + AdminAccessInfo.builder().admin(false).matchingAdminRule(matching.getId()).build(); + this.adminAccessCache.put(req, grant); + return grant; + } + + private AccessRequest req(Rule r) { + return AccessRequest.builder() + .roles("TEST_ROLE") + .workspace(r.getIdentifier().getWorkspace()) + .layer(r.getIdentifier().getLayer()) + .build(); + } +} diff --git a/src/integration/spring/domain/pom.xml b/src/integration/spring/domain/pom.xml index 3444d8c..59ef110 100644 --- a/src/integration/spring/domain/pom.xml +++ b/src/integration/spring/domain/pom.xml @@ -45,6 +45,11 @@ org.projectlombok lombok + + org.slf4j + slf4j-api + provided + org.springframework.boot spring-boot-starter-test diff --git a/src/integration/spring/domain/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java b/src/integration/spring/domain/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java deleted file mode 100644 index ed4057a..0000000 --- a/src/integration/spring/domain/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java +++ /dev/null @@ -1,145 +0,0 @@ -/* (c) 2023 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.acl.authorization.cache; - -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.CaffeineSpec; -import com.github.benmanes.caffeine.cache.LoadingCache; - -import lombok.NonNull; - -import org.geoserver.acl.authorization.AccessInfo; -import org.geoserver.acl.authorization.AccessRequest; -import org.geoserver.acl.authorization.AdminAccessInfo; -import org.geoserver.acl.authorization.AdminAccessRequest; -import org.geoserver.acl.authorization.AuthorizationService; -import org.geoserver.acl.domain.adminrules.AdminRuleEvent; -import org.geoserver.acl.domain.rules.Rule; -import org.geoserver.acl.domain.rules.RuleEvent; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; - -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import java.util.function.Predicate; - -public class CachingAuthorizationService implements AuthorizationService { - - private final AuthorizationService delegate; - - private LoadingCache ruleAccessCache; - private LoadingCache adminRuleAccessCache; - - CachingAuthorizationService( - @NonNull AuthorizationService delegate, @NonNull CaffeineSpec spec) { - this(delegate, spec, spec); - } - - public CachingAuthorizationService( - @NonNull AuthorizationService delegate, - @NonNull CaffeineSpec rulesSpec, - @NonNull CaffeineSpec adminRulesSpec) { - - this.delegate = delegate; - ruleAccessCache = Caffeine.from(rulesSpec).build(this.delegate::getAccessInfo); - adminRuleAccessCache = - Caffeine.from(adminRulesSpec).build(this.delegate::getAdminAuthorization); - } - - @Override - public AccessInfo getAccessInfo(AccessRequest request) { - return ruleAccessCache.get(request); - } - - @Override - public AdminAccessInfo getAdminAuthorization(AdminAccessRequest request) { - return adminRuleAccessCache.get(request); - } - - @Override - public List getMatchingRules(AccessRequest request) { - return delegate.getMatchingRules(request); - } - - @Async - @EventListener(RuleEvent.class) - public void onRuleEvent(RuleEvent event) { - switch (event.getEventType()) { - case DELETED: - case UPDATED: - evictRuleAccessCache(event.getRuleIds()); - break; - case CREATED: - default: - break; - } - } - - @Async - @EventListener(AdminRuleEvent.class) - public void onAdminRuleEvent(AdminRuleEvent event) { - switch (event.getEventType()) { - case DELETED: - case UPDATED: - evictAdminAccessCache(event.getRuleIds()); - break; - case CREATED: - default: - break; - } - } - - private void evictRuleAccessCache(Set affectedRuleIds) { - List matchingRequests = - ruleAccessCache.asMap().entrySet().stream() - .parallel() - .filter( - e -> - e.getValue().getMatchingRules().stream() - .anyMatch(affectedRuleIds::contains)) - .map(Map.Entry::getKey) - .toList(); - - ruleAccessCache.invalidateAll(matchingRequests); - } - - private void evictAdminAccessCache(Set affectedRuleIds) { - Predicate> adminRulePredicate = - e -> - e.getValue().getMatchingAdminRule() != null - && affectedRuleIds.contains(e.getValue().getMatchingAdminRule()); - - List matchingRequests; - - matchingRequests = - adminRuleAccessCache.asMap().entrySet().stream() - .parallel() - .filter(adminRulePredicate) - .map(Map.Entry::getKey) - .toList(); - - adminRuleAccessCache.invalidateAll(matchingRequests); - } - - public static CachingAuthorizationService newShortLivedInstanceForClient( - AuthorizationService delegate) { - String clientSettings = ""; - return fromSpec(delegate, clientSettings); - } - - public static CachingAuthorizationService newLongLivedInstanceForServer( - AuthorizationService delegate) { - String serverSettings = ""; - return fromSpec(delegate, serverSettings); - } - - private static CachingAuthorizationService fromSpec( - AuthorizationService delegate, String serverSettings) { - CaffeineSpec spec = CaffeineSpec.parse(serverSettings); - return new CachingAuthorizationService(delegate, spec); - } -} diff --git a/src/integration/spring/pom.xml b/src/integration/spring/pom.xml index 30d010b..fd217de 100644 --- a/src/integration/spring/pom.xml +++ b/src/integration/spring/pom.xml @@ -16,5 +16,6 @@ pom domain + cache diff --git a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java index 8238276..c0af976 100644 --- a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java +++ b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java @@ -7,7 +7,6 @@ package org.geoserver.acl.plugin.accessmanager; import static java.util.logging.Level.FINE; -import static java.util.logging.Level.FINER; import static java.util.logging.Level.WARNING; import com.google.common.base.Stopwatch; @@ -241,24 +240,17 @@ private AccessLimits getAccessLimits( List containers) { // shortcut, if the user is the admin, he can do everything if (isAdmin(user)) { - log(FINER, "Admin level access, returning full rights for layer {0}", layer); + log(FINE, "Admin level access, returning full rights for layer {0}", layer); return buildAdminAccessLimits(info); } AccessRequest accessRequest = buildAccessRequest(workspace, layer, user); - Stopwatch sw = Stopwatch.createStarted(); - AccessInfo accessInfo = aclService.getAccessInfo(accessRequest); - sw.stop(); - log(FINE, "ACL auth run in {0}: {0}. response ({1}): {2}", sw, accessRequest, accessInfo); - - if (accessInfo == null) { - accessInfo = AccessInfo.DENY_ALL; - log(WARNING, "ACL returning null AccessInfo for {0}", accessRequest); - } + AccessInfo accessInfo = getAccessInfo(accessRequest); final Request req = Dispatcher.REQUEST.get(); final String service = req != null ? req.getService() : null; final boolean isWms = "WMS".equalsIgnoreCase(service); + final boolean isWps = "WPS".equalsIgnoreCase(service); final boolean layerGroupsRequested = CollectionUtils.isNotEmpty(containers); ProcessingResult processingResult = null; @@ -279,35 +271,29 @@ private AccessLimits getAccessLimits( } } } else if (layerGroupsRequested) { - // layer is requested in context of a layer group. - // we need to process the containers limits. + // layer is requested in context of a layer group, we need to process the containers + // limits. processingResult = getContainerResolverResult(info, layer, workspace, user, containers, List.of()); } - if ("WPS".equalsIgnoreCase(service)) { + if (isWps) { if (layerGroupsRequested) { log( WARNING, "Don't know how to deal with WPS requests for group data. Won't dive into single process limits."); } else { WPSAccessInfo resolvedAccessInfo = - wpsHelper.resolveWPSAccess(req, accessRequest, accessInfo); + wpsHelper.resolveWPSAccess(accessRequest, accessInfo); if (resolvedAccessInfo != null) { accessInfo = resolvedAccessInfo.getAccessInfo(); - processingResult = - new ProcessingResult( - resolvedAccessInfo.getArea(), - resolvedAccessInfo.getClip(), - accessInfo.getCatalogMode()); - - String userNameFromAuth = getUserNameFromAuth(user); + processingResult = wpsProcessingResult(accessInfo, resolvedAccessInfo); log( FINE, "Got WPS access {0} for layer {1} and user {2}", accessInfo, layer, - userNameFromAuth); + getUserNameFromAuth(user)); } } } @@ -315,12 +301,13 @@ private AccessLimits getAccessLimits( AccessLimits limits; if (info instanceof LayerGroupInfo) { limits = buildLayerGroupAccessLimits(accessInfo); - } else if (info instanceof ResourceInfo) { - limits = buildResourceAccessLimits((ResourceInfo) info, accessInfo, processingResult); + } else if (info instanceof ResourceInfo ri) { + limits = buildResourceAccessLimits(ri, accessInfo, processingResult); + } else if (info instanceof LayerInfo li) { + limits = buildResourceAccessLimits(li.getResource(), accessInfo, processingResult); } else { - limits = - buildResourceAccessLimits( - ((LayerInfo) info).getResource(), accessInfo, processingResult); + throw new IllegalArgumentException( + "Expected LayerInfo|LayerGroupInfo|ResourceInfo, got " + info); } log( @@ -333,6 +320,31 @@ private AccessLimits getAccessLimits( return limits; } + private ProcessingResult wpsProcessingResult( + AccessInfo accessInfo, WPSAccessInfo wpsAccessInfo) { + ProcessingResult processingResult; + processingResult = + new ProcessingResult( + wpsAccessInfo.getArea(), + wpsAccessInfo.getClip(), + accessInfo.getCatalogMode()); + return processingResult; + } + + private AccessInfo getAccessInfo(AccessRequest accessRequest) { + Stopwatch sw = Stopwatch.createStarted(); + AccessInfo accessInfo = aclService.getAccessInfo(accessRequest); + sw.stop(); + log(FINE, "ACL auth run in {0}: {1} -> {2}", sw, accessRequest, accessInfo); + + if (accessInfo == null) { + accessInfo = AccessInfo.DENY_ALL; + log(WARNING, "ACL returned null for {0}, defaulting to DENY_ALL", accessRequest); + } + + return accessInfo; + } + private static void log(Level level, String msg, Object... params) { if (LOGGER.isLoggable(level)) { LOGGER.log(level, msg, params); @@ -354,9 +366,8 @@ private AccessLimits buildAdminAccessLimits(CatalogInfo info) { AccessLimits accessLimits; if (info instanceof LayerGroupInfo) accessLimits = buildLayerGroupAccessLimits(AccessInfo.ALLOW_ALL); - else if (info instanceof ResourceInfo) - accessLimits = - buildResourceAccessLimits((ResourceInfo) info, AccessInfo.ALLOW_ALL, null); + else if (info instanceof ResourceInfo ri) + accessLimits = buildResourceAccessLimits(ri, AccessInfo.ALLOW_ALL, null); else accessLimits = buildResourceAccessLimits( @@ -374,10 +385,9 @@ private String getUserNameFromAuth(Authentication authentication) { private Collection getGroupSummary(Object resource) { Collection summaries; - if (resource instanceof ResourceInfo) - summaries = groupsCache.getContainerGroupsFor((ResourceInfo) resource); - else if (resource instanceof LayerInfo) - summaries = groupsCache.getContainerGroupsFor(((LayerInfo) resource).getResource()); + if (resource instanceof ResourceInfo ri) summaries = groupsCache.getContainerGroupsFor(ri); + else if (resource instanceof LayerInfo li) + summaries = groupsCache.getContainerGroupsFor(li.getResource()); else summaries = groupsCache.getContainerGroupsFor((LayerGroupInfo) resource); return summaries == null ? List.of() : summaries; } @@ -479,23 +489,18 @@ private VectorAccessLimits buildVectorAccessLimits( areaFilter = FF.or(areaFilter, intersectClipArea); } readFilter = mergeFilter(readFilter, areaFilter); - writeFilter = mergeFilter(writeFilter, areaFilter); } // get the attributes - final List readAttributes = - toPropertyNames(accessInfo.getAttributes(), PropertyAccessMode.READ); - final List writeAttributes = - toPropertyNames(accessInfo.getAttributes(), PropertyAccessMode.WRITE); + var readAttributes = toPropertyNames(accessInfo.getAttributes(), PropertyAccessMode.READ); + var writeAttributes = toPropertyNames(accessInfo.getAttributes(), PropertyAccessMode.WRITE); - VectorAccessLimits accessLimits = + var accessLimits = new VectorAccessLimits( catalogMode, readAttributes, readFilter, writeAttributes, writeFilter); - if (clipArea != null) { - accessLimits.setClipVectorFilter(clipArea); - } + if (clipArea != null) accessLimits.setClipVectorFilter(clipArea); if (intersectsArea != null) accessLimits.setIntersectVectorFilter(intersectsArea); return accessLimits; diff --git a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/wps/WPSHelper.java b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/wps/WPSHelper.java index 6115d96..caa9df9 100644 --- a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/wps/WPSHelper.java +++ b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/wps/WPSHelper.java @@ -16,7 +16,6 @@ import org.geoserver.acl.domain.rules.LayerAttribute; import org.geoserver.acl.plugin.support.AccessInfoUtils; import org.geoserver.acl.plugin.support.GeomHelper; -import org.geoserver.ows.Request; import org.geotools.util.logging.Logging; import org.locationtech.jts.geom.Geometry; import org.springframework.beans.BeansException; @@ -68,7 +67,7 @@ public void setApplicationContext(ApplicationContext applicationContext) throws * resolution was computed. */ public WPSAccessInfo resolveWPSAccess( - final Request req, final AccessRequest accessRequest, final AccessInfo wpsAccessInfo) { + final AccessRequest accessRequest, final AccessInfo wpsAccessInfo) { if (!helperAvailable) { LOGGER.warning("WPSHelper not available"); // For more security we should deny the access, anyway let's tell diff --git a/src/plugin/client/pom.xml b/src/plugin/client/pom.xml index 754c36c..83226c2 100644 --- a/src/plugin/client/pom.xml +++ b/src/plugin/client/pom.xml @@ -41,5 +41,9 @@ + + org.awaitility + awaitility + diff --git a/src/plugin/client/src/main/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfiguration.java b/src/plugin/client/src/main/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfiguration.java index b94fa59..99f3d10 100644 --- a/src/plugin/client/src/main/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfiguration.java +++ b/src/plugin/client/src/main/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfiguration.java @@ -49,7 +49,7 @@ RuleAdminServiceConfiguration.class, AdminRuleAdminServiceConfiguration.class, }) -@Slf4j +@Slf4j(topic = "org.geoserver.acl.plugin.config.domain.client") public class ApiClientAclDomainServicesConfiguration { @Bean @@ -58,13 +58,24 @@ ApiClientProperties aclApiClientProperties(Environment env) { String username = env.getProperty("geoserver.acl.client.username"); String password = env.getProperty("geoserver.acl.client.password"); boolean debug = env.getProperty("geoserver.acl.client.debug", Boolean.class, false); + boolean caching = env.getProperty("geoserver.acl.client.caching", Boolean.class, true); + boolean startupCheck = + env.getProperty("geoserver.acl.client.startupCheck", Boolean.class, true); + Integer initTimeout = env.getProperty("geoserver.acl.client.initTimeout", Integer.class); - log.info("GeoServer Acess Control List server URL: " + basePath); + log.info( + "GeoServer Acess Control List API: {}, user: {}, caching: {}", + basePath, + username, + caching); ApiClientProperties configProps = new ApiClientProperties(); configProps.setBasePath(basePath); configProps.setUsername(username); configProps.setPassword(password); configProps.setDebug(debug); + configProps.setCaching(caching); + configProps.setStartupCheck(startupCheck); + if (null != initTimeout) configProps.setInitTimeout(initTimeout); return configProps; } diff --git a/src/plugin/client/src/test/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfigurationTest.java b/src/plugin/client/src/test/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfigurationTest.java index 4a497c0..9c80940 100644 --- a/src/plugin/client/src/test/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfigurationTest.java +++ b/src/plugin/client/src/test/java/org/geoserver/acl/plugin/config/domain/client/ApiClientAclDomainServicesConfigurationTest.java @@ -32,7 +32,8 @@ void testConfiguredThroughConfigProperties() { runner.withPropertyValues( "geoserver.acl.client.basePath=http://localhost:8181/acl/api", "geoserver.acl.client.username=testme", - "geoserver.acl.client.password=s3cr3t") + "geoserver.acl.client.password=s3cr3t", + "geoserver.acl.client.startupCheck=false") .run( context -> { assertThat(context).hasNotFailed(); @@ -43,4 +44,22 @@ void testConfiguredThroughConfigProperties() { .hasSingleBean(AuthorizationServiceClientAdaptor.class); }); } + + @Test + void testStartupCheck() { + runner.withPropertyValues( + "geoserver.acl.client.basePath=http://localhost:8181/acl/api", + "geoserver.acl.client.username=testme", + "geoserver.acl.client.password=s3cr3t", + "geoserver.acl.client.startupCheck=true", + "geoserver.acl.client.initTimeout=2") + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasMessageContaining( + "Unable to connect to ACL after 2 seconds"); + }); + } } diff --git a/src/plugin/plugin/pom.xml b/src/plugin/plugin/pom.xml index fad2024..4241541 100644 --- a/src/plugin/plugin/pom.xml +++ b/src/plugin/plugin/pom.xml @@ -13,6 +13,10 @@ org.geoserver.acl.plugin gs-acl-plugin-accessmanager + + org.geoserver.acl.integration + gs-acl-cache + org.geoserver.acl.plugin gs-acl-plugin-client @@ -105,6 +109,10 @@ provided true + + org.projectlombok + lombok + org.springframework.boot spring-boot-starter-test diff --git a/src/plugin/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/cache/CachingAuthorizationServiceAutoConfiguration.java b/src/plugin/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/cache/CachingAuthorizationServiceAutoConfiguration.java new file mode 100644 index 0000000..bf3affa --- /dev/null +++ b/src/plugin/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/cache/CachingAuthorizationServiceAutoConfiguration.java @@ -0,0 +1,36 @@ +/* (c) 2023 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.acl.plugin.autoconfigure.cache; + +import lombok.extern.slf4j.Slf4j; + +import org.geoserver.acl.authorization.cache.CachingAuthorizationServiceConfiguration; +import org.geoserver.acl.plugin.autoconfigure.accessmanager.AclAccessManagerAutoConfiguration; +import org.geoserver.acl.plugin.autoconfigure.accessmanager.ConditionalOnAclEnabled; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Import; + +import javax.annotation.PostConstruct; + +/** + * @since 1.0 + * @see CachingAuthorizationServiceConfiguration + */ +@AutoConfiguration(after = AclAccessManagerAutoConfiguration.class) +@ConditionalOnAclEnabled +@ConditionalOnProperty( + name = "geoserver.acl.client.caching", + havingValue = "true", + matchIfMissing = true) +@Import(CachingAuthorizationServiceConfiguration.class) +@Slf4j(topic = "org.geoserver.acl.plugin.autoconfigure.cache") +public class CachingAuthorizationServiceAutoConfiguration { + + @PostConstruct + void logUsing() { + log.info("Caching ACL AuthorizationService enabled"); + } +} diff --git a/src/plugin/plugin/src/main/resources/META-INF/spring.factories b/src/plugin/plugin/src/main/resources/META-INF/spring.factories index 29c3587..9bfa47c 100644 --- a/src/plugin/plugin/src/main/resources/META-INF/spring.factories +++ b/src/plugin/plugin/src/main/resources/META-INF/spring.factories @@ -1,4 +1,5 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.geoserver.acl.plugin.autoconfigure.accessmanager.AclAccessManagerAutoConfiguration,\ org.geoserver.acl.plugin.autoconfigure.webui.AclWebUIAutoConfiguration,\ -org.geoserver.acl.plugin.autoconfigure.wps.AclWpsAutoConfiguration \ No newline at end of file +org.geoserver.acl.plugin.autoconfigure.wps.AclWpsAutoConfiguration,\ +org.geoserver.acl.plugin.autoconfigure.cache.CachingAuthorizationServiceAutoConfiguration \ No newline at end of file diff --git a/src/plugin/plugin/src/test/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/AclAccessManagerAutoConfigurationTest.java b/src/plugin/plugin/src/test/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/AclAccessManagerAutoConfigurationTest.java index e76b674..1e710ee 100644 --- a/src/plugin/plugin/src/test/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/AclAccessManagerAutoConfigurationTest.java +++ b/src/plugin/plugin/src/test/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/AclAccessManagerAutoConfigurationTest.java @@ -32,7 +32,9 @@ class AclAccessManagerAutoConfigurationTest { @Test void testEnabledByDefaultWhenServiceUrlIsProvided() { - runner.withPropertyValues("geoserver.acl.client.basePath=http://acl.test:9000") + runner.withPropertyValues( + "geoserver.acl.client.startupCheck=false", + "geoserver.acl.client.basePath=http://acl.test:9000") .run( context -> { assertThat(context) @@ -59,6 +61,7 @@ void testConditionalOnAclEnabled() { runner.withPropertyValues( "geoserver.acl.enabled=true", + "geoserver.acl.client.startupCheck=false", "geoserver.acl.client.basePath=http://acl.test:9000") .run( context -> { diff --git a/src/plugin/web/src/main/java/org/geoserver/acl/plugin/web/accessrules/model/AccessRequestSimulatorModel.java b/src/plugin/web/src/main/java/org/geoserver/acl/plugin/web/accessrules/model/AccessRequestSimulatorModel.java index be57405..fd10d07 100644 --- a/src/plugin/web/src/main/java/org/geoserver/acl/plugin/web/accessrules/model/AccessRequestSimulatorModel.java +++ b/src/plugin/web/src/main/java/org/geoserver/acl/plugin/web/accessrules/model/AccessRequestSimulatorModel.java @@ -82,10 +82,7 @@ public boolean runSimulation() { private AccessInfo getAccessInfo() { AccessRequest request = getModel().getObject().toRequest(); AuthorizationService authorizationService = authorizationService(); - AccessInfo accessInfo = authorizationService.getAccessInfo(request); - log.info("{}", request); - log.info("{}", accessInfo); - return accessInfo; + return authorizationService.getAccessInfo(request); } public boolean isMatchingRules() {