diff --git a/docker-test/src/main/resources/keycloak/test-realm.json b/docker-test/src/main/resources/keycloak/test-realm.json
index 579731c..5c48976 100644
--- a/docker-test/src/main/resources/keycloak/test-realm.json
+++ b/docker-test/src/main/resources/keycloak/test-realm.json
@@ -1162,6 +1162,10 @@
"groups": [
{
"name": "Test A",
+ "attributes": {
+ "alfrescoGroupName": ["Group_A"],
+ "alfrescoGroupDisplayName": ["Group with custom mapped attributes"]
+ },
"subGroups": [
{
"name": "Test AA"
diff --git a/docker-test/src/main/resources/repository/alfresco-global.addition.properties b/docker-test/src/main/resources/repository/alfresco-global.addition.properties
index 6f86a4d..5450f9e 100644
--- a/docker-test/src/main/resources/repository/alfresco-global.addition.properties
+++ b/docker-test/src/main/resources/repository/alfresco-global.addition.properties
@@ -3,6 +3,7 @@ db.url=jdbc:postgresql://alf-pg:5432/alfresco
db.username=alfresco
db.password=alfresco
+index.subsystem.name=solr6
solr.host=solr6
solr.port=8983
@@ -29,5 +30,6 @@ keycloak.roles.requiredClientScopes=alfresco-role-service
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A
+keycloak.synchronization.groupFilter.pathMatch.property.groupPaths=/Test A
keycloak.synchronization.requiredClientScopes=alfresco-authority-sync
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index c00f64f..827b5d6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -88,7 +88,7 @@
9.0
- 1.4.3
+ 1.4.6-SNAPSHOT
1.2.2.0
keycloak/keycloak
diff --git a/repository/module.properties b/repository/module.properties
index ca5c4af..16c68bd 100644
--- a/repository/module.properties
+++ b/repository/module.properties
@@ -5,4 +5,4 @@ module.version=${noSnapshotVersion}
module.repo.version.min=5
-module.depends.acosix-utility=1.2.5-*
\ No newline at end of file
+module.depends.acosix-utility=1.4.6-*
\ No newline at end of file
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
index ee56e94..1297f79 100644
--- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
@@ -202,10 +202,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
index 3a2a50c..b5fa90f 100644
--- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
@@ -145,11 +145,25 @@ keycloak.synchronization.userFilter.containedInGroup.property.requireAll=false
keycloak.synchronization.userFilter.containedInGroup.property.allowTransitive=true
keycloak.synchronization.userFilter.containedInGroup.property.groupLoadBatchSize=${keycloak.synchronization.groupLoadBatchSize}
+keycloak.synchronization.userFilter.notContainedInGroup.property.groupPaths=
+keycloak.synchronization.userFilter.notContainedInGroup.property.groupIds=
+keycloak.synchronization.userFilter.notContainedInGroup.property.requireAll=false
+keycloak.synchronization.userFilter.notContainedInGroup.property.allowTransitive=true
+keycloak.synchronization.userFilter.notContainedInGroup.property.groupLoadBatchSize=${keycloak.synchronization.groupLoadBatchSize}
+
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=
keycloak.synchronization.groupFilter.containedInGroup.property.groupIds=
keycloak.synchronization.groupFilter.containedInGroup.property.requireAll=false
keycloak.synchronization.groupFilter.containedInGroup.property.allowTransitive=true
+keycloak.synchronization.groupFilter.notContainedInGroup.property.groupPaths=
+keycloak.synchronization.groupFilter.notContainedInGroup.property.groupIds=
+keycloak.synchronization.groupFilter.notContainedInGroup.property.requireAll=false
+keycloak.synchronization.groupFilter.notContainedInGroup.property.allowTransitive=true
+
+keycloak.synchronization.groupFilter.pathMatch.property.groupPaths=
+keycloak.synchronization.groupFilter.notPathMatch.property.groupPaths=
+
keycloak.synchronization.userMapper.default.property.enabled=true
keycloak.synchronization.userMapper.default.property.mapNull=true
keycloak.synchronization.userMapper.default.property.mapFirstName=true
@@ -158,6 +172,7 @@ keycloak.synchronization.userMapper.default.property.mapEmail=true
keycloak.synchronization.userMapper.default.property.mapEnabledState=true
keycloak.synchronization.userMapper.simpleAttributes.property.enabled=true
+keycloak.synchronization.userMapper.simpleAttributes.property.mapBlankString=true
keycloak.synchronization.userMapper.simpleAttributes.property.mapNull=true
keycloak.synchronization.userMapper.simpleAttributes.property.attributes.map.middleName=cm:middleName
keycloak.synchronization.userMapper.simpleAttributes.property.attributes.map.organization=cm:organization
@@ -176,4 +191,8 @@ keycloak.synchronization.userMapper.simpleAttributes.property.attributes.map.com
keycloak.synchronization.groupMapper.default.property.enabled=true
keycloak.synchronization.groupMapper.simpleAttributes.property.enabled=true
-keycloak.synchronization.groupMapper.simpleAttributes.property.mapNull=true
\ No newline at end of file
+keycloak.synchronization.groupMapper.simpleAttributes.property.mapBlankString=false
+keycloak.synchronization.groupMapper.simpleAttributes.property.mapNull=false
+# these are not available in default Keycloak groups, but can be optionally set to override default mapping
+keycloak.synchronization.groupMapper.simpleAttributes.property.attributes.map.alfrescoGroupName=cm:authorityName
+keycloak.synchronization.groupMapper.simpleAttributes.property.attributes.map.alfrescoGroupDisplayName=cm:authorityDisplayName
\ No newline at end of file
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClient.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClient.java
index 8f6ecad..a3dc855 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClient.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClient.java
@@ -92,6 +92,17 @@ public interface IdentitiesClient
*/
int processGroups(int offset, int groupBatchSize, Consumer groupProcessor);
+ /**
+ * Loads and processes a sub-groups from Keycloak using an externally specified processor.
+ *
+ * @param groupId
+ * the ID of the parent group
+ * @param groupProcessor
+ * the processor handling the loaded groups
+ * @return the number of processed groups
+ */
+ int processSubGroups(String groupId, Consumer groupProcessor);
+
/**
* Loads and processes a batch of users / members of a group from Keycloak using an externally specified processor.
*
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClientImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClientImpl.java
index be63131..b80928c 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClientImpl.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IdentitiesClientImpl.java
@@ -158,7 +158,8 @@ public class IdentitiesClientImpl extends AbstractIDMClientImpl implements Ident
ParameterCheck.mandatory("groupProcessor", groupProcessor);
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/groups")
- .queryParam("first", offset).queryParam("max", groupBatchSize).build(this.deployment.getRealm());
+ .queryParam("first", offset).queryParam("max", groupBatchSize).queryParam("briefRepresentation", false)
+ .build(this.deployment.getRealm());
if (offset < 0)
{
@@ -172,6 +173,23 @@ public class IdentitiesClientImpl extends AbstractIDMClientImpl implements Ident
return this.processEntityBatch(uri, groupProcessor, GroupRepresentation.class);
}
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public int processSubGroups(final String groupId, final Consumer groupProcessor)
+ {
+ ParameterCheck.mandatoryString("groupId", groupId);
+ ParameterCheck.mandatory("groupProcessor", groupProcessor);
+
+ final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
+ .path("/admin/realms/{realm}/groups/{groupId}/children").substitutePathParam("groupId", groupId, false)
+ .queryParam("briefRepresentation", false).build(this.deployment.getRealm());
+
+ return this.processEntityBatch(uri, groupProcessor, GroupRepresentation.class);
+ }
+
/**
*
* {@inheritDoc}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java
index 6d8df60..c0727b8 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.Predicate;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.namespace.NamespaceService;
@@ -37,6 +38,8 @@ public abstract class BaseAttributeProcessor implements InitializingBean
protected NamespaceService namespaceService;
+ protected boolean mapBlankString;
+
protected boolean mapNull;
protected Map attributes;
@@ -55,14 +58,13 @@ public abstract class BaseAttributeProcessor implements InitializingBean
if (this.attributes != null && !this.attributes.isEmpty())
{
this.attributePropertyQNameMappings = new HashMap<>();
- this.attributes
- .forEach((k, v) -> this.attributePropertyQNameMappings.put(k, QName.resolveToQName(this.namespaceService, v)));
+ this.attributes.forEach((k, v) -> this.attributePropertyQNameMappings.put(k, QName.resolveToQName(this.namespaceService, v)));
}
}
/**
* @param namespaceService
- * the namespaceService to set
+ * the namespaceService to set
*/
public void setNamespaceService(final NamespaceService namespaceService)
{
@@ -71,16 +73,25 @@ public abstract class BaseAttributeProcessor implements InitializingBean
/**
* @param attributes
- * the attributes to set
+ * the attributes to set
*/
public void setAttributes(final Map attributes)
{
this.attributes = attributes;
}
+ /**
+ * @param mapBlankString
+ * the mapBlankString to set
+ */
+ public void setMapBlankString(final boolean mapBlankString)
+ {
+ this.mapBlankString = mapBlankString;
+ }
+
/**
* @param mapNull
- * the mapNull to set
+ * the mapNull to set
*/
public void setMapNull(final boolean mapNull)
{
@@ -95,9 +106,9 @@ public abstract class BaseAttributeProcessor implements InitializingBean
* property, again leaving that kind of processing to the Alfresco default functionality of integrity checking.
*
* @param attributes
- * the list of attributes
+ * the list of attributes
* @param nodeDescription
- * the node description to enhance
+ * the node description to enhance
*/
protected void map(final Map> attributes, final NodeDescription nodeDescription)
{
@@ -112,11 +123,11 @@ public abstract class BaseAttributeProcessor implements InitializingBean
* Maps an individual attribute to the correlating node property of the node description.
*
* @param attribute
- * the name of the attribute to map
+ * the name of the attribute to map
* @param attributes
- * the list of attributes
+ * the list of attributes
* @param nodeDescription
- * the node description to enhance
+ * the node description to enhance
*/
protected void mapAttribute(final String attribute, final Map> attributes, final NodeDescription nodeDescription)
{
@@ -128,12 +139,30 @@ public abstract class BaseAttributeProcessor implements InitializingBean
if (values.size() == 1)
{
value = values.get(0);
+ if (!this.mapBlankString && ((String) value).isBlank())
+ {
+ value = null;
+ }
}
else
{
- value = new ArrayList<>(values);
+ if (!this.mapBlankString)
+ {
+ value = new ArrayList<>(values.stream().filter(Predicate.not(String::isBlank)).toList());
+ if (((List>) value).isEmpty())
+ {
+ value = null;
+ }
+ }
+ else
+ {
+ value = new ArrayList<>(values);
+ }
+ }
+ if (value != null || this.mapNull)
+ {
+ nodeDescription.getProperties().put(propertyQName, value);
}
- nodeDescription.getProperties().put(propertyQName, value);
}
else if (this.mapNull)
{
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java
index aa5d9ca..ef08f1f 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java
@@ -42,6 +42,8 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
protected List idResolvedGroupPaths;
+ protected boolean matchDenies;
+
protected boolean requireAll = false;
protected boolean allowTransitive = true;
@@ -75,7 +77,7 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/**
* @param groupPaths
- * the groupPaths to set as a comma-separated string of paths
+ * the groupPaths to set as a comma-separated string of paths
*/
public void setGroupPaths(final String groupPaths)
{
@@ -84,16 +86,25 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/**
* @param groupIds
- * the groupIds to set as a comma-separated string of paths
+ * the groupIds to set as a comma-separated string of paths
*/
public void setGroupIds(final String groupIds)
{
this.groupIds = groupIds != null && !groupIds.isEmpty() ? Arrays.asList(groupIds.split(",")) : null;
}
+ /**
+ * @param matchDenies
+ * the matchDenies to set
+ */
+ public void setMatchDenies(final boolean matchDenies)
+ {
+ this.matchDenies = matchDenies;
+ }
+
/**
* @param requireAll
- * the requireAll to set
+ * the requireAll to set
*/
public void setRequireAll(final boolean requireAll)
{
@@ -102,7 +113,7 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/**
* @param allowTransitive
- * the allowTransitive to set
+ * the allowTransitive to set
*/
public void setAllowTransitive(final boolean allowTransitive)
{
@@ -111,7 +122,7 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/**
* @param groupLoadBatchSize
- * the groupLoadBatchSize to set
+ * the groupLoadBatchSize to set
*/
public void setGroupLoadBatchSize(final int groupLoadBatchSize)
{
@@ -122,9 +133,9 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
* Checks whether parent groups match the configured restrictions.
*
* @param parentGroupIds
- * the list of parent group IDs for an authority
+ * the list of parent group IDs for an authority
* @param parentGroupPaths
- * the list of parent group paths for an authority
+ * the list of parent group paths for an authority
* @return {@code true} if the parent groups match the configured restrictions, {@code false} otherwise
*/
protected boolean parentGroupsMatch(final List parentGroupIds, final List parentGroupPaths)
@@ -177,9 +188,9 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
* Checks whether a specific group path matches any entry in a list of paths using either exact match or prefix matching.
*
* @param groupPath
- * the path to check
+ * the path to check
* @param groupPaths
- * the paths to check against
+ * the paths to check against
* @return {@code true} if the path matches one of the paths in exact match or prefix matching mode
*/
protected boolean groupPathOrTransitiveContained(final String groupPath, final Collection groupPaths)
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java
index cf420f3..54c34bc 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java
@@ -17,7 +17,7 @@ package de.acosix.alfresco.keycloak.repo.sync;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.security.sync.NodeDescription;
-import org.alfresco.service.cmr.security.AuthorityType;
+import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.util.PropertyMap;
import org.keycloak.representations.idm.GroupRepresentation;
@@ -51,8 +51,19 @@ public class DefaultGroupProcessor implements GroupProcessor
{
final PropertyMap properties = groupNode.getProperties();
- properties.put(ContentModel.PROP_AUTHORITY_NAME, AuthorityType.GROUP.getPrefixString() + group.getId());
- properties.put(ContentModel.PROP_AUTHORITY_DISPLAY_NAME, group.getName());
+ final String existingName = DefaultTypeConverter.INSTANCE.convert(String.class,
+ properties.get(ContentModel.PROP_AUTHORITY_NAME));
+ final String existingDisplayName = DefaultTypeConverter.INSTANCE.convert(String.class,
+ properties.get(ContentModel.PROP_AUTHORITY_DISPLAY_NAME));
+
+ if (existingName == null || existingName.isBlank())
+ {
+ properties.put(ContentModel.PROP_AUTHORITY_NAME, group.getId());
+ }
+ if (existingDisplayName == null || existingDisplayName.isBlank())
+ {
+ properties.put(ContentModel.PROP_AUTHORITY_DISPLAY_NAME, group.getName());
+ }
}
}
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/FilterResult.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/FilterResult.java
new file mode 100644
index 0000000..ba2d4c1
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/FilterResult.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2019 - 2025 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.sync;
+
+/**
+ * Values of this enumeration denote the decision/vote of a filter for synchronisation of a user or group.
+ *
+ * @author Axel Faust
+ */
+public enum FilterResult
+{
+
+ ALLOW,
+ DENY,
+ ABSTAIN;
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java
index de8b364..17e70c6 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java
@@ -15,9 +15,7 @@
*/
package de.acosix.alfresco.keycloak.repo.sync;
-import java.util.ArrayList;
import java.util.Collections;
-import java.util.List;
import org.keycloak.representations.idm.GroupRepresentation;
import org.slf4j.Logger;
@@ -29,8 +27,7 @@ import org.slf4j.LoggerFactory;
*
* @author Axel Faust
*/
-public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter
- implements GroupFilter
+public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter implements GroupFilter
{
private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentGroupFilter.class);
@@ -40,38 +37,46 @@ public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter
* {@inheritDoc}
*/
@Override
- public boolean shouldIncludeGroup(final GroupRepresentation group)
+ public FilterResult shouldIncludeGroup(final GroupRepresentation group)
{
- boolean matches;
+ final FilterResult res;
if ((this.groupPaths != null && !this.groupPaths.isEmpty()) || (this.groupIds != null && !this.groupIds.isEmpty()))
{
LOGGER.debug(
- "Checking group {} ({}) for containment in groups with paths {} / IDs {}, using allowTransitive={} and requireAll={}",
- group.getId(), group.getPath(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll);
+ "Checking group {} ({}) for containment in groups with paths {} / IDs {}, using allowTransitive={}, requireAll={}, matchDenies={}",
+ group.getId(), group.getPath(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll,
+ this.matchDenies);
// no need to retrieve parent group ID as path should be sufficient
// Keycloak groups can only ever have one parent
-
- final List parentGroupIds = Collections.emptyList();
- final List parentGroupPaths = new ArrayList<>();
-
final String groupPath = group.getPath();
final String parentPath = groupPath.substring(0, groupPath.lastIndexOf('/'));
if (!parentPath.isEmpty())
{
- parentGroupPaths.add(parentPath);
+ final boolean parentGroupsMatch = this.parentGroupsMatch(Collections.emptyList(), Collections.singletonList(parentPath));
+ if (this.matchDenies)
+ {
+ res = parentGroupsMatch ? FilterResult.DENY : FilterResult.ABSTAIN;
+ }
+ else
+ {
+ res = parentGroupsMatch ? FilterResult.ALLOW : FilterResult.ABSTAIN;
+ }
+ }
+ else
+ {
+ // no parents to check
+ res = FilterResult.ABSTAIN;
}
- matches = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
-
- LOGGER.debug("Group containment result for group {}: {}", group.getId(), matches);
+ LOGGER.debug("Group containment result for group {}: {}", group.getId(), res);
}
else
{
- matches = true;
+ res = FilterResult.ABSTAIN;
}
- return matches;
+ return res;
}
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java
index db6eaa0..bb3d2d8 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java
@@ -29,8 +29,7 @@ import org.springframework.beans.factory.InitializingBean;
*
* @author Axel Faust
*/
-public class GroupContainmentUserFilter extends BaseGroupContainmentFilter
- implements UserFilter, InitializingBean
+public class GroupContainmentUserFilter extends BaseGroupContainmentFilter implements UserFilter, InitializingBean
{
private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentUserFilter.class);
@@ -40,14 +39,15 @@ public class GroupContainmentUserFilter extends BaseGroupContainmentFilter
* {@inheritDoc}
*/
@Override
- public boolean shouldIncludeUser(final UserRepresentation user)
+ public FilterResult shouldIncludeUser(final UserRepresentation user)
{
- boolean matches;
+ final FilterResult res;
if ((this.groupPaths != null && !this.groupPaths.isEmpty()) || (this.groupIds != null && !this.groupIds.isEmpty()))
{
- LOGGER.debug("Checking user {} for containment in groups with paths {} / IDs {}, using allowTransitive={} and requireAll={}",
- user.getUsername(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll);
+ LOGGER.debug(
+ "Checking user {} for containment in groups with paths {} / IDs {}, using allowTransitive={}, requireAll={}, matchDenies={}",
+ user.getUsername(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll, this.matchDenies);
final List parentGroupIds = new ArrayList<>();
final List parentGroupPaths = new ArrayList<>();
@@ -63,15 +63,31 @@ public class GroupContainmentUserFilter extends BaseGroupContainmentFilter
offset += processedGroups;
}
- matches = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
+ if (parentGroupIds.isEmpty() || parentGroupPaths.isEmpty())
+ {
+ final boolean parentGroupsMatch = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
+ if (this.matchDenies)
+ {
+ res = parentGroupsMatch ? FilterResult.DENY : FilterResult.ABSTAIN;
+ }
+ else
+ {
+ res = parentGroupsMatch ? FilterResult.ALLOW : FilterResult.ABSTAIN;
+ }
+ }
+ else
+ {
+ // no parents to check
+ res = FilterResult.ABSTAIN;
+ }
- LOGGER.debug("Group containment result for user {}: {}", user.getUsername(), matches);
+ LOGGER.debug("Group containment result for user {}: {}", user.getUsername(), res);
}
else
{
- matches = true;
+ res = FilterResult.ABSTAIN;
}
- return matches;
+ return res;
}
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java
index fed87ed..482b06c 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java
@@ -31,8 +31,8 @@ public interface GroupFilter
* Determines whether this group should be included in the synchronisation.
*
* @param group
- * the group to consider
- * @return {@code true} if the group should be synchronised, {@code false} if not
+ * the group to consider
+ * @return the filter result
*/
- boolean shouldIncludeGroup(GroupRepresentation group);
+ FilterResult shouldIncludeGroup(GroupRepresentation group);
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupPathFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupPathFilter.java
new file mode 100644
index 0000000..ab09e87
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupPathFilter.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2019 - 2025 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.sync;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class provides filter capabilities for groups to be synchronised based on their parent group and whether they are contained in
+ * specific groups.
+ *
+ * @author Axel Faust
+ */
+public class GroupPathFilter implements GroupFilter
+{
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(GroupPathFilter.class);
+
+ protected List groupPaths;
+
+ protected boolean matchDenies;
+
+ /**
+ * @param groupPaths
+ * the groupPaths to set as a comma-separated string of paths
+ */
+ public void setGroupPaths(final String groupPaths)
+ {
+ this.groupPaths = groupPaths != null && !groupPaths.isEmpty() ? Arrays.asList(groupPaths.split(",")) : null;
+ }
+
+ /**
+ * @param matchDenies
+ * the matchDenies to set
+ */
+ public void setMatchDenies(final boolean matchDenies)
+ {
+ this.matchDenies = matchDenies;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public FilterResult shouldIncludeGroup(final GroupRepresentation group)
+ {
+ final FilterResult res;
+
+ if ((this.groupPaths != null && !this.groupPaths.isEmpty()))
+ {
+ LOGGER.debug("Checking group {} ({}) against paths {}, using matchDenies={}", group.getId(), group.getPath(), this.groupPaths,
+ this.matchDenies);
+
+ final String groupPath = group.getPath();
+ final boolean containsGroup = this.groupPaths.contains(groupPath);
+ if (this.matchDenies)
+ {
+ res = containsGroup ? FilterResult.DENY : FilterResult.ABSTAIN;
+ }
+ else
+ {
+ res = containsGroup ? FilterResult.ALLOW : FilterResult.ABSTAIN;
+ }
+
+ LOGGER.debug("Group path check result for group {}: {}", group.getId(), res);
+ }
+ else
+ {
+ res = FilterResult.ABSTAIN;
+ }
+
+ return res;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java
index c07e14a..bf92898 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java
@@ -30,10 +30,12 @@ import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.IntConsumer;
+import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.repo.security.sync.UserRegistry;
+import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.service.cmr.security.AuthorityType;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyCheck;
@@ -263,22 +265,28 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
*/
protected NodeDescription mapGroup(final GroupRepresentation group)
{
- // need to use group ID as unique name as Keycloak group name itself is non-unique
-
final NodeDescription groupD = new NodeDescription(group.getId());
final PropertyMap groupProperties = groupD.getProperties();
- final String groupName = AuthorityType.GROUP.getPrefixString() + group.getId();
- LOGGER.debug("Mapping group {}", groupName);
+ LOGGER.debug("Mapping group {} ({})", group.getName(), group.getId());
this.groupProcessors.forEach(processor -> processor.mapGroup(group, groupD));
- // always wins against user-defined mappings for cm:authorityName
+ // make sure groupName is mapped + prefixed
+ String groupName = DefaultTypeConverter.INSTANCE.convert(String.class, groupProperties.get(ContentModel.PROP_AUTHORITY_NAME));
+ if (groupName == null || groupName.isBlank())
+ {
+ // should never happen due to DefaultGroupProcessor
+ groupName = group.getId();
+ }
+ if (AuthorityType.getAuthorityType(groupName) != AuthorityType.GROUP)
+ {
+ groupName = AuthorityType.GROUP.getPrefixString() + group.getId();
+ }
groupProperties.put(ContentModel.PROP_AUTHORITY_NAME, groupName);
final Set childAssociations = groupD.getChildAssociations();
- group.getSubGroups().stream()
- .filter(subGroup -> !this.groupFilters.stream().anyMatch(filter -> !filter.shouldIncludeGroup(subGroup)))
+ group.getSubGroups().stream().filter(subGroup -> isGroupAllowed(this.groupFilters, subGroup))
.forEach(subGroup -> childAssociations.add(AuthorityType.GROUP.getPrefixString() + subGroup.getId()));
int offset = 0;
@@ -286,8 +294,7 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
while (processedMembers > 0)
{
processedMembers = this.identitiesClient.processMembers(group.getId(), offset, this.personLoadBatchSize, user -> {
- final boolean skipSync = this.userFilters.stream().anyMatch(filter -> !filter.shouldIncludeUser(user));
- if (!skipSync)
+ if (KeycloakUserRegistry.isUserAllowed(this.userFilters, user))
{
childAssociations.add(user.getUsername());
}
@@ -483,8 +490,7 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
// TODO Evaluate other iteration approaches, e.g. crawling from a configured root group
// How to count totals in advance though?
return KeycloakUserRegistry.this.identitiesClient.processUsers(offset, batchSize, user -> {
- final boolean skipSync = KeycloakUserRegistry.this.userFilters.stream().anyMatch(filter -> !filter.shouldIncludeUser(user));
- if (!skipSync)
+ if (KeycloakUserRegistry.isUserAllowed(KeycloakUserRegistry.this.userFilters, user))
{
authorityProcessor.accept(user);
}
@@ -530,16 +536,17 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
{
// TODO Evaluate other iteration approaches, e.g. crawling from a configured root group
// How to count totals in advance though?
- return KeycloakUserRegistry.this.identitiesClient.processGroups(offset, batchSize, group -> {
- this.processGroupsRecursively(group, filteredHandler, authorityProcessor);
+ final AtomicInteger count = new AtomicInteger();
+ final int loadedDirect = KeycloakUserRegistry.this.identitiesClient.processGroups(offset, batchSize, group -> {
+ this.processGroupsRecursively(group, filteredHandler, authorityProcessor, count);
});
+ return count.addAndGet(loadedDirect);
}
protected void processGroupsRecursively(final GroupRepresentation group, final IntConsumer filteredHandler,
- final Consumer authorityProcessor)
+ final Consumer authorityProcessor, final AtomicInteger count)
{
- final boolean skipSync = KeycloakUserRegistry.this.groupFilters.stream().anyMatch(filter -> !filter.shouldIncludeGroup(group));
- if (!skipSync)
+ if (KeycloakUserRegistry.isGroupAllowed(KeycloakUserRegistry.this.groupFilters, group))
{
authorityProcessor.accept(group);
}
@@ -548,9 +555,58 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
filteredHandler.accept(1);
}
- // any filtering applied above does not apply here as any sub-group will be individually checked for filtering by recursive
- // processing
- group.getSubGroups().forEach(subGroup -> this.processGroupsRecursively(subGroup, filteredHandler, authorityProcessor));
+ final List subGroups = group.getSubGroups();
+ if (subGroups == null || subGroups.isEmpty())
+ {
+ try
+ {
+ final int loadedChilren = KeycloakUserRegistry.this.identitiesClient.processSubGroups(group.getId(), subGroup -> {
+ this.processGroupsRecursively(subGroup, filteredHandler, authorityProcessor, count);
+ });
+ count.addAndGet(loadedChilren);
+ }
+ catch (final AlfrescoRuntimeException ex)
+ {
+ LOGGER.warn("Failed to load sub groups for {} ({})", group.getName(), group.getId(), ex);
+ }
+ }
+ else
+ {
+ subGroups.stream().forEach(subGroup -> this.processGroupsRecursively(subGroup, filteredHandler, authorityProcessor, count));
+ count.addAndGet(subGroups.size());
+ }
}
}
+
+ private static boolean isUserAllowed(final Collection filters, final UserRepresentation user)
+ {
+ final FilterResult res = filters.stream().map(f -> f.shouldIncludeUser(user)).reduce(KeycloakUserRegistry::combine)
+ .orElse(FilterResult.ABSTAIN);
+ return res == FilterResult.ALLOW;
+ }
+
+ private static boolean isGroupAllowed(final Collection filters, final GroupRepresentation group)
+ {
+ final FilterResult res = filters.stream().map(f -> f.shouldIncludeGroup(group)).reduce(KeycloakUserRegistry::combine)
+ .orElse(FilterResult.ABSTAIN);
+ return res == FilterResult.ALLOW;
+ }
+
+ private static FilterResult combine(final FilterResult a, final FilterResult b)
+ {
+ FilterResult res;
+ if (a == FilterResult.DENY || b == FilterResult.DENY)
+ {
+ res = FilterResult.DENY;
+ }
+ else if (a == FilterResult.ABSTAIN)
+ {
+ res = b;
+ }
+ else
+ {
+ res = a;
+ }
+ return res;
+ }
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java
index c65240f..c02d73f 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java
@@ -31,8 +31,8 @@ public interface UserFilter
* Determines whether this user should be included in the synchronisation.
*
* @param user
- * the user to consider
- * @return {@code true} if the user should be synchronised, {@code false} if not
+ * the user to consider
+ * @return the filter result
*/
- boolean shouldIncludeUser(UserRepresentation user);
+ FilterResult shouldIncludeUser(UserRepresentation user);
}
diff --git a/share/pom.xml b/share/pom.xml
index fd7c485..86950cd 100644
--- a/share/pom.xml
+++ b/share/pom.xml
@@ -194,6 +194,27 @@
false
+
+
+ org.keycloak:*
+
+ META-INF/MANIFEST.MF
+
+
+
+ org.jboss.logging:*
+
+ META-INF/MANIFEST.MF
+
+
+
+ com.fasterxml.jackson.core:*
+
+ META-INF/MANIFEST.MF
+ META-INF/versions/9/module-info.class
+
+
+