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 6754225..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
@@ -7,10 +7,23 @@
package org.geoserver.acl.authorization;
+import static org.geoserver.acl.domain.adminrules.AdminGrantType.ADMIN;
+import static org.geoserver.acl.domain.adminrules.AdminGrantType.USER;
+import static org.geoserver.acl.domain.rules.CatalogMode.CHALLENGE;
+import static org.geoserver.acl.domain.rules.CatalogMode.HIDE;
+import static org.geoserver.acl.domain.rules.CatalogMode.MIXED;
+import static org.geoserver.acl.domain.rules.GrantType.ALLOW;
+import static org.geoserver.acl.domain.rules.GrantType.DENY;
+import static org.geoserver.acl.domain.rules.GrantType.LIMIT;
+import static org.geoserver.acl.domain.rules.LayerAttribute.AccessType.READONLY;
+import static org.geoserver.acl.domain.rules.LayerAttribute.AccessType.READWRITE;
+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;
-import org.geoserver.acl.domain.adminrules.AdminGrantType;
import org.geoserver.acl.domain.adminrules.AdminRule;
import org.geoserver.acl.domain.adminrules.AdminRuleAdminService;
import org.geoserver.acl.domain.adminrules.AdminRuleFilter;
@@ -20,7 +33,6 @@
import org.geoserver.acl.domain.rules.CatalogMode;
import org.geoserver.acl.domain.rules.GrantType;
import org.geoserver.acl.domain.rules.LayerAttribute;
-import org.geoserver.acl.domain.rules.LayerAttribute.AccessType;
import org.geoserver.acl.domain.rules.LayerDetails;
import org.geoserver.acl.domain.rules.Rule;
import org.geoserver.acl.domain.rules.RuleAdminService;
@@ -34,7 +46,6 @@
import org.geotools.geometry.jts.JTS;
import org.geotools.referencing.CRS;
import org.locationtech.jts.geom.Geometry;
-import org.locationtech.jts.geom.MultiPolygon;
import java.util.ArrayList;
import java.util.HashMap;
@@ -42,6 +53,7 @@
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -52,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 {
@@ -64,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);
@@ -79,45 +91,29 @@ private List flatten(Map> found) {
}
@Override
- public AccessInfo getAccessInfo(AccessRequest request) {
+ public AccessInfo getAccessInfo(@NonNull AccessRequest request) {
request = request.validate();
- log.debug("Requesting access for {}", request);
- Map> groupedRules = getMatchingRulesByRole(request);
- AccessInfo currAccessInfo = null;
+ Map> groupedRules = getMatchingRulesByRole(request);
- // List flattened = flatten(groupedRules);
- // currAccessInfo = resolveRuleset(flattened);
+ AccessInfo ret = null;
for (Entry> ruleGroup : groupedRules.entrySet()) {
- String role = ruleGroup.getKey();
List rules = ruleGroup.getValue();
-
AccessInfo accessInfo = resolveRuleset(rules);
- if (log.isDebugEnabled()) {
- log.debug("Filter {} on role {} has access {}", request, role, accessInfo);
- }
-
- currAccessInfo = enlargeAccessInfo(currAccessInfo, accessInfo);
+ ret = enlargeAccessInfo(ret, accessInfo);
}
- AccessInfo ret;
+ if (null == ret) ret = AccessInfo.DENY_ALL;
- if (currAccessInfo == null) {
- log.debug("No access for filter " + request);
- // Denying by default
- ret = AccessInfo.DENY_ALL;
- } else {
- ret = currAccessInfo;
- }
-
- log.debug("Returning {} for {}", ret, request);
List matchingIds = flatten(groupedRules).stream().map(Rule::getId).toList();
- return ret.withMatchingRules(matchingIds);
+ ret = ret.withMatchingRules(matchingIds);
+ 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);
@@ -131,14 +127,14 @@ public AdminAccessInfo getAdminAuthorization(AdminAccessRequest request) {
private AccessInfo enlargeAccessInfo(AccessInfo baseAccess, AccessInfo moreAccess) {
if (baseAccess == null) {
if (moreAccess == null) return null;
- else if (moreAccess.getGrant() == GrantType.ALLOW) return moreAccess;
+ else if (moreAccess.getGrant() == ALLOW) return moreAccess;
else return null;
} else {
if (moreAccess == null) return baseAccess;
- else if (moreAccess.getGrant() == GrantType.DENY) return baseAccess;
+ else if (moreAccess.getGrant() == DENY) return baseAccess;
else {
// ok: extending grants
- AccessInfo.Builder ret = AccessInfo.builder().grant(GrantType.ALLOW);
+ AccessInfo.Builder ret = AccessInfo.builder().grant(ALLOW);
String cqlRead =
unionCQL(baseAccess.getCqlFilterRead(), moreAccess.getCqlFilterRead());
@@ -157,13 +153,10 @@ private AccessInfo enlargeAccessInfo(AccessInfo baseAccess, AccessInfo moreAcces
ret.defaultStyle(baseAccess.getDefaultStyle()); // just pick one
}
- Set allowedStyles =
- unionAllowedStyles(
- baseAccess.getAllowedStyles(), moreAccess.getAllowedStyles());
+ Set allowedStyles = unionAllowedStyles(baseAccess, moreAccess);
ret.allowedStyles(allowedStyles);
- Set attributes =
- unionAttributes(baseAccess.getAttributes(), moreAccess.getAttributes());
+ Set attributes = unionAttributes(baseAccess, moreAccess);
ret.attributes(attributes);
setAllowedAreas(baseAccess, moreAccess, ret);
@@ -210,69 +203,74 @@ private Geometry toJTS(org.geolatte.geom.Geometry> geom) {
private String unionCQL(String c1, String c2) {
if (c1 == null || c2 == null) return null;
-
- return "(" + c1 + ") OR (" + c2 + ")";
+ return "(%s) OR (%s)".formatted(c1, c2);
}
private Geometry unionGeometry(Geometry g1, Geometry g2) {
if (g1 == null || g2 == null) return null;
- return union(g1, g2);
+ int targetSRID = g1.getSRID();
+ Geometry result = g1.union(reprojectGeometry(targetSRID, g2));
+ result.setSRID(targetSRID);
+ return result;
+ }
+
+ private static Set unionAttributes(AccessInfo a0, AccessInfo a1) {
+ return unionAttributes(a0.getAttributes(), a1.getAttributes());
}
private static Set unionAttributes(
Set a0, Set a1) {
- // TODO: check how geoserver deals with empty set
-
- if (a0 == null || a0.isEmpty()) return Set.of();
- // return a1;
- if (a1 == null || a1.isEmpty()) return Set.of();
- // return a0;
+ if (null == a0) a0 = Set.of();
+ if (null == a1) a1 = Set.of();
+ // if at least one of the two set is empty, the result will be an empty set,
+ // that means attributes are not restricted
+ if (a0.isEmpty() || a1.isEmpty()) return Set.of();
- Set ret = new HashSet();
+ Set ret = new HashSet<>();
// add both attributes only in a0, and enlarge common attributes
for (LayerAttribute attr0 : a0) {
- LayerAttribute attr1 = getAttribute(attr0.getName(), a1);
- if (attr1 == null) {
- ret.add(attr0);
- } else {
- LayerAttribute attr = attr0;
- if (attr0.getAccess() == AccessType.READWRITE
- || attr1.getAccess() == AccessType.READWRITE)
- attr = attr.withAccess(AccessType.READWRITE);
- else if (attr0.getAccess() == AccessType.READONLY
- || attr1.getAccess() == AccessType.READONLY)
- attr = attr.withAccess(AccessType.READONLY);
- ret.add(attr);
- }
+ getAttribute(attr0.getName(), a1)
+ .ifPresentOrElse(
+ attr1 -> ret.add(enlargeAccess(attr0, attr1)), () -> ret.add(attr0));
}
// now add attributes that are only in a1
for (LayerAttribute attr1 : a1) {
- LayerAttribute attr0 = getAttribute(attr1.getName(), a0);
- if (attr0 == null) {
- ret.add(attr1);
- }
+ getAttribute(attr1.getName(), a0)
+ .ifPresentOrElse(
+ attr0 -> log.trace("ignoring att {}", attr0.getName()),
+ () -> ret.add(attr1));
}
return ret;
}
- private static LayerAttribute getAttribute(String name, Set set) {
- for (LayerAttribute layerAttribute : set) {
- if (layerAttribute.getName().equals(name)) return layerAttribute;
- }
- return null;
+ private static LayerAttribute enlargeAccess(LayerAttribute attr0, LayerAttribute attr1) {
+ LayerAttribute attr = attr0;
+ if (attr0.getAccess() == READWRITE || attr1.getAccess() == READWRITE)
+ attr = attr.withAccess(READWRITE);
+ else if (attr0.getAccess() == READONLY || attr1.getAccess() == READONLY)
+ attr = attr.withAccess(READONLY);
+ return attr;
+ }
+
+ private static Optional getAttribute(String name, Set set) {
+ return set.stream().filter(la -> name.equals(la.getName())).findFirst();
+ }
+
+ private static Set unionAllowedStyles(AccessInfo a0, AccessInfo a1) {
+ return unionAllowedStyles(a0.getAllowedStyles(), a1.getAllowedStyles());
}
private static Set unionAllowedStyles(Set a0, Set a1) {
+ if (null == a0) a0 = Set.of();
+ if (null == a1) a1 = Set.of();
// if at least one of the two set is empty, the result will be an empty set,
// that means styles are not restricted
- if (a0 == null || a0.isEmpty()) return Set.of();
+ if (a0.isEmpty() || a1.isEmpty()) return Set.of();
- if (a1 == null || a1.isEmpty()) return Set.of();
-
- Set allowedStyles = new HashSet();
+ Set allowedStyles = new HashSet<>();
allowedStyles.addAll(a0);
allowedStyles.addAll(a1);
return allowedStyles;
@@ -283,51 +281,35 @@ private AccessInfo resolveRuleset(List ruleList) {
List limits = new ArrayList<>();
AccessInfo ret = null;
for (Rule rule : ruleList) {
- if (ret != null) break;
-
- switch (rule.getIdentifier().getAccess()) {
- case LIMIT:
- RuleLimits rl = rule.getRuleLimits();
- if (rl != null) {
- log.trace("Collecting limits: {}", rl);
- limits.add(rl);
- } else
- log.trace(
- "Rule has no associated limits (id: {}, priority: {})",
- rule.getId(),
- rule.getPriority());
- break;
-
- case DENY:
- ret = AccessInfo.DENY_ALL;
- break;
-
+ final GrantType access = rule.getIdentifier().getAccess();
+ switch (access) {
case ALLOW:
- ret = buildAllowAccessInfo(rule, limits);
+ return buildAllowAccessInfo(rule, limits);
+ case DENY:
+ return AccessInfo.DENY_ALL;
+ case LIMIT:
+ if (null != rule.getRuleLimits()) limits.add(rule.getRuleLimits());
break;
-
default:
- throw new IllegalStateException(
- "Unknown GrantType " + rule.getIdentifier().getAccess());
+ throw new IllegalStateException("Unknown GrantType " + access);
}
}
return ret;
}
private AccessInfo buildAllowAccessInfo(Rule rule, List limits) {
- AccessInfo.Builder accessInfo = AccessInfo.builder().grant(GrantType.ALLOW);
+ AccessInfo.Builder accessInfo = AccessInfo.builder().grant(ALLOW);
// first intersects geometry of same type
Geometry area = intersect(limits);
boolean atLeastOneClip =
- limits.stream()
- .anyMatch(l -> l.getSpatialFilterType().equals(SpatialFilterType.CLIP));
+ limits.stream().map(RuleLimits::getSpatialFilterType).anyMatch(CLIP::equals);
CatalogMode cmode = resolveCatalogMode(limits);
final LayerDetails details = getLayerDetails(rule);
if (null != details) {
// intersect the allowed area of the rule to the proper type
SpatialFilterType spatialFilterType = getSpatialFilterType(rule, details);
- atLeastOneClip = spatialFilterType.equals(SpatialFilterType.CLIP);
+ atLeastOneClip = spatialFilterType.equals(CLIP);
area = intersect(area, toJTS(details.getArea()));
@@ -343,13 +325,13 @@ private AccessInfo buildAllowAccessInfo(Rule rule, List limits) {
accessInfo.catalogMode(cmode);
if (area != null) {
- // if we have a clip area we apply clip type
- // since is more restrictive, otherwise we keep
- // the intersect
+ // if we have a clip area we apply clip type since is more restrictive, otherwise we
+ // keep the intersect
+ org.geolatte.geom.Geometry> finalArea = org.geolatte.geom.jts.JTS.from(area);
if (atLeastOneClip) {
- accessInfo.clipArea(org.geolatte.geom.jts.JTS.from(area));
+ accessInfo.clipArea(finalArea);
} else {
- accessInfo.area(org.geolatte.geom.jts.JTS.from(area));
+ accessInfo.area(finalArea);
}
}
return accessInfo.build();
@@ -365,63 +347,41 @@ private LayerDetails getLayerDetails(Rule rule) {
private SpatialFilterType getSpatialFilterType(Rule rule, LayerDetails details) {
SpatialFilterType spatialFilterType = null;
- if (GrantType.LIMIT.equals(rule.getIdentifier().getAccess())
- && null != rule.getRuleLimits()) {
+ if (LIMIT.equals(rule.getIdentifier().getAccess()) && null != rule.getRuleLimits()) {
spatialFilterType = rule.getRuleLimits().getSpatialFilterType();
} else if (null != details) {
spatialFilterType = details.getSpatialFilterType();
}
- if (null == spatialFilterType) spatialFilterType = SpatialFilterType.INTERSECT;
+ if (null == spatialFilterType) spatialFilterType = INTERSECT;
return spatialFilterType;
}
private Geometry intersect(List limits) {
- org.locationtech.jts.geom.Geometry g = null;
- for (RuleLimits limit : limits) {
- org.locationtech.jts.geom.MultiPolygon area =
- (MultiPolygon) toJTS(limit.getAllowedArea());
- if (area != null) {
- if (g == null) {
- g = area;
- } else {
- int targetSRID = g.getSRID();
- g = g.intersection(reprojectGeometry(targetSRID, area));
- g.setSRID(targetSRID);
- }
- }
+ List geoms =
+ limits.stream()
+ .map(RuleLimits::getAllowedArea)
+ .filter(Objects::nonNull)
+ .map(this::toJTS)
+ .toList();
+ if (geoms.isEmpty()) return null;
+ if (1 == geoms.size()) return geoms.get(0);
+
+ org.locationtech.jts.geom.Geometry intersection = geoms.get(0);
+ for (int i = 1; i < geoms.size(); i++) {
+ intersection = intersect(intersection, geoms.get(i));
}
- return g;
+ return intersection;
}
private Geometry intersect(Geometry g1, Geometry g2) {
- if (g1 != null) {
- if (g2 == null) {
- return g1;
- } else {
- int targetSRID = g1.getSRID();
- Geometry result = g1.intersection(reprojectGeometry(targetSRID, g2));
- result.setSRID(targetSRID);
- return result;
- }
- } else {
- return g2;
- }
- }
+ if (g1 == null) return g2;
+ if (g2 == null) return g1;
- private Geometry union(Geometry g1, Geometry g2) {
- if (g1 != null) {
- if (g2 == null) {
- return g1;
- } else {
- int targetSRID = g1.getSRID();
- Geometry result = g1.union(reprojectGeometry(targetSRID, g2));
- result.setSRID(targetSRID);
- return result;
- }
- } else {
- return g2;
- }
+ int targetSRID = g1.getSRID();
+ Geometry result = g1.intersection(reprojectGeometry(targetSRID, g2));
+ result.setSRID(targetSRID);
+ return result;
}
/** Returns the stricter catalog mode. */
@@ -438,11 +398,11 @@ protected static CatalogMode getStricter(CatalogMode m1, CatalogMode m2) {
if (m1 == null) return m2;
if (m2 == null) return m1;
- if (CatalogMode.HIDE == m1 || CatalogMode.HIDE == m2) return CatalogMode.HIDE;
+ if (HIDE == m1 || HIDE == m2) return HIDE;
- if (CatalogMode.MIXED == m1 || CatalogMode.MIXED == m2) return CatalogMode.MIXED;
+ if (MIXED == m1 || MIXED == m2) return MIXED;
- return CatalogMode.CHALLENGE;
+ return CHALLENGE;
}
protected static CatalogMode getLarger(CatalogMode m1, CatalogMode m2) {
@@ -450,12 +410,11 @@ protected static CatalogMode getLarger(CatalogMode m1, CatalogMode m2) {
if (m1 == null) return m2;
if (m2 == null) return m1;
- if (CatalogMode.CHALLENGE == m1 || CatalogMode.CHALLENGE == m2)
- return CatalogMode.CHALLENGE;
+ if (CHALLENGE == m1 || CHALLENGE == m2) return CHALLENGE;
- if (CatalogMode.MIXED == m1 || CatalogMode.MIXED == m2) return CatalogMode.MIXED;
+ if (MIXED == m1 || MIXED == m2) return MIXED;
- return CatalogMode.HIDE;
+ return HIDE;
}
// ==========================================================================
@@ -475,7 +434,6 @@ protected static CatalogMode getLarger(CatalogMode m1, CatalogMode m2) {
protected Map> getMatchingRulesByRole(AccessRequest request)
throws IllegalArgumentException {
- // RuleFilter filter = request.getFilter();
RuleFilter filter = new RuleFilter(SpecialFilterType.DEFAULT);
filter.getUser().setHeuristically(request.getUser());
@@ -491,8 +449,7 @@ protected Map> getMatchingRulesByRole(AccessRequest request)
Map> ret = new HashMap<>();
- final Set finalRoleFilter =
- filter.getRole().getValues(); // validateUserRoles(request);
+ final Set finalRoleFilter = filter.getRole().getValues();
if (finalRoleFilter.isEmpty()) {
if (filter.getRole().getType() != FilterType.ANY) {
filter = filter.clone();
@@ -516,96 +473,26 @@ private List getRulesByRole(RuleFilter filter, String role) {
return ruleService.getAll(RuleQuery.of(filter)).toList();
}
- /**
- * Check requested user and group filter.
- * The input filter may be altered for fixing some request inconsistencies.
- *
- * @param filter
- * @return a Set of group names, or null if provided user/group are invalid.
- * @throws IllegalArgumentException
- */
- // protected Set validateUserRoles(String username, Set userRoles) throws
- // IllegalArgumentException {
- //
- // // username can be null if the user filter asks for ANY or DEFAULT
- // //String username = validateUsername(filter.getUser());
- //
- // Set finalRoleFilter = new HashSet<>();
- // // If both user and group are defined in filter
- // // if user doesn't belong to group, no rule is returned
- // // otherwise assigned or default rules are searched for
- //
- // switch (filter.getRole().getType()) {
- // case NAMEVALUE:
- // // rolename can be null if the group filter asks for ANY or DEFAULT
- // final Set requestedRoles = userRoles;//validateRolenames(userRoles);
- // // rolenames
- //
- // if (username != null) {
- // for (String role : requestedRoles) {
- // if (userRoles.contains(role)) {
- // finalRoleFilter.add(role);
- // } else {
- // log.debug(
- // "User does not belong to role [User:{}] [Role:{}]
- // [ResolvedRoles:{}]",
- // filter.getUser(),
- // role,
- // userRoles);
- // }
- // }
- // } else {
- // finalRoleFilter.addAll(requestedRoles);
- // }
- // break;
- //
- // case ANY:
- // if (username != null) {
- // Set resolvedRoles = request.userRoles();
- // if (!resolvedRoles.isEmpty()) {
- // finalRoleFilter = resolvedRoles;
- // } else {
- // filter.setRole(SpecialFilterType.DEFAULT);
- // }
- // } else {
- // // no changes, use requested filtering
- // }
- // break;
- //
- // default:
- // // no changes
- // break;
- // }
- //
- // return finalRoleFilter;
- // }
-
- // ==========================================================================
-
private boolean isAdminAuth(Optional rule) {
- return rule.isEmpty() ? false : rule.orElseThrow().getAccess() == AdminGrantType.ADMIN;
+ return rule.map(AdminRule::getAccess).orElse(USER) == ADMIN;
}
private Optional getAdminAuth(AdminAccessRequest request) {
request = request.validate();
- // AdminRuleFilter adminRuleFilter = AdminRuleFilter.of(request.getFilter());
AdminRuleFilter adminRuleFilter = new AdminRuleFilter();
adminRuleFilter.getSourceAddress().setHeuristically(request.getSourceAddress());
adminRuleFilter.getUser().setHeuristically(request.getUser());
adminRuleFilter.getRole().setHeuristically(request.getRoles());
adminRuleFilter.getWorkspace().setHeuristically(request.getWorkspace());
- Set finalRoleFilter =
- adminRuleFilter.getRole().getValues(); // validateUserRoles(request);
+ Set finalRoleFilter = adminRuleFilter.getRole().getValues();
if (finalRoleFilter.isEmpty()) {
return adminRuleService.getFirstMatch(adminRuleFilter);
}
- // adminRuleFilter.setRole(RuleFilter.asTextValue(finalRoleFilter));
adminRuleFilter.getRole().setIncludeDefault(true);
- Optional found = adminRuleService.getFirstMatch(adminRuleFilter);
- return found;
+ return adminRuleService.getFirstMatch(adminRuleFilter);
}
private Geometry reprojectGeometry(int targetSRID, Geometry geom) {
@@ -618,13 +505,13 @@ private Geometry reprojectGeometry(int targetSRID, Geometry geom) {
result.setSRID(targetSRID);
return result;
} catch (FactoryException e) {
- throw new RuntimeException(
+ throw new IllegalStateException(
"Unable to find transformation for SRIDs: "
+ geom.getSRID()
+ " to "
+ targetSRID);
} catch (TransformException e) {
- throw new RuntimeException(
+ throw new IllegalStateException(
"Unable to reproject geometry from " + geom.getSRID() + " to " + targetSRID);
}
}
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 super Entry> 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() {