Improved group synchronisation

- add exact path match condition to allow selection of specific groups
- add negative match conditions to exclude specific groups/users
- support group names derived from attibutes
This commit is contained in:
AFaust 2025-02-21 23:07:45 +01:00 committed by Axel Faust
parent 725f768535
commit 4b1b0cbd08
19 changed files with 422 additions and 81 deletions

View File

@ -1162,6 +1162,10 @@
"groups": [ "groups": [
{ {
"name": "Test A", "name": "Test A",
"attributes": {
"alfrescoGroupName": ["Group_A"],
"alfrescoGroupDisplayName": ["Group with custom mapped attributes"]
},
"subGroups": [ "subGroups": [
{ {
"name": "Test AA" "name": "Test AA"

View File

@ -3,6 +3,7 @@ db.url=jdbc:postgresql://alf-pg:5432/alfresco
db.username=alfresco db.username=alfresco
db.password=alfresco db.password=alfresco
index.subsystem.name=solr6
solr.host=solr6 solr.host=solr6
solr.port=8983 solr.port=8983
@ -29,5 +30,6 @@ keycloak.roles.requiredClientScopes=alfresco-role-service
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A
keycloak.synchronization.groupFilter.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 keycloak.synchronization.requiredClientScopes=alfresco-authority-sync

View File

@ -88,7 +88,7 @@
<!-- parent is including 6.11 erroneously --> <!-- parent is including 6.11 erroneously -->
<surf.version>9.0</surf.version> <surf.version>9.0</surf.version>
<acosix.utility.version>1.4.3</acosix.utility.version> <acosix.utility.version>1.4.6-SNAPSHOT</acosix.utility.version>
<ootbee.support-tools.version>1.2.2.0</ootbee.support-tools.version> <ootbee.support-tools.version>1.2.2.0</ootbee.support-tools.version>
<keycloak.docker.image>keycloak/keycloak</keycloak.docker.image> <keycloak.docker.image>keycloak/keycloak</keycloak.docker.image>

View File

@ -5,4 +5,4 @@ module.version=${noSnapshotVersion}
module.repo.version.min=5 module.repo.version.min=5
module.depends.acosix-utility=1.2.5-* module.depends.acosix-utility=1.4.6-*

View File

@ -202,10 +202,26 @@
<property name="identitiesClient" ref="identitiesClient" /> <property name="identitiesClient" ref="identitiesClient" />
</bean> </bean>
<bean id="userFilter.notContainedInGroup" class="${project.artifactId}.sync.GroupContainmentUserFilter">
<property name="identitiesClient" ref="identitiesClient" />
<property name="matchDenies" value="true" />
</bean>
<bean id="groupFilter.pathMatch" class="${project.artifactId}.sync.GroupPathFilter" />
<bean id="groupFilter.notPathMatch" class="${project.artifactId}.sync.GroupPathFilter">
<property name="matchDenies" value="true" />
</bean>
<bean id="groupFilter.containedInGroup" class="${project.artifactId}.sync.GroupContainmentGroupFilter"> <bean id="groupFilter.containedInGroup" class="${project.artifactId}.sync.GroupContainmentGroupFilter">
<property name="identitiesClient" ref="identitiesClient" /> <property name="identitiesClient" ref="identitiesClient" />
</bean> </bean>
<bean id="groupFilter.notContainedInGroup" class="${project.artifactId}.sync.GroupContainmentGroupFilter">
<property name="identitiesClient" ref="identitiesClient" />
<property name="matchDenies" value="true" />
</bean>
<bean id="authorityMapper.simpleAttributes" abstract="true"> <bean id="authorityMapper.simpleAttributes" abstract="true">
<property name="namespaceService" ref="namespaceService" /> <property name="namespaceService" ref="namespaceService" />
</bean> </bean>

View File

@ -145,11 +145,25 @@ keycloak.synchronization.userFilter.containedInGroup.property.requireAll=false
keycloak.synchronization.userFilter.containedInGroup.property.allowTransitive=true keycloak.synchronization.userFilter.containedInGroup.property.allowTransitive=true
keycloak.synchronization.userFilter.containedInGroup.property.groupLoadBatchSize=${keycloak.synchronization.groupLoadBatchSize} 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.groupPaths=
keycloak.synchronization.groupFilter.containedInGroup.property.groupIds= keycloak.synchronization.groupFilter.containedInGroup.property.groupIds=
keycloak.synchronization.groupFilter.containedInGroup.property.requireAll=false keycloak.synchronization.groupFilter.containedInGroup.property.requireAll=false
keycloak.synchronization.groupFilter.containedInGroup.property.allowTransitive=true 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.enabled=true
keycloak.synchronization.userMapper.default.property.mapNull=true keycloak.synchronization.userMapper.default.property.mapNull=true
keycloak.synchronization.userMapper.default.property.mapFirstName=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.default.property.mapEnabledState=true
keycloak.synchronization.userMapper.simpleAttributes.property.enabled=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.mapNull=true
keycloak.synchronization.userMapper.simpleAttributes.property.attributes.map.middleName=cm:middleName keycloak.synchronization.userMapper.simpleAttributes.property.attributes.map.middleName=cm:middleName
keycloak.synchronization.userMapper.simpleAttributes.property.attributes.map.organization=cm:organization 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.default.property.enabled=true
keycloak.synchronization.groupMapper.simpleAttributes.property.enabled=true keycloak.synchronization.groupMapper.simpleAttributes.property.enabled=true
keycloak.synchronization.groupMapper.simpleAttributes.property.mapNull=true 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

View File

@ -92,6 +92,17 @@ public interface IdentitiesClient
*/ */
int processGroups(int offset, int groupBatchSize, Consumer<GroupRepresentation> groupProcessor); int processGroups(int offset, int groupBatchSize, Consumer<GroupRepresentation> 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<GroupRepresentation> groupProcessor);
/** /**
* Loads and processes a batch of users / members of a group from Keycloak using an externally specified processor. * Loads and processes a batch of users / members of a group from Keycloak using an externally specified processor.
* *

View File

@ -158,7 +158,8 @@ public class IdentitiesClientImpl extends AbstractIDMClientImpl implements Ident
ParameterCheck.mandatory("groupProcessor", groupProcessor); ParameterCheck.mandatory("groupProcessor", groupProcessor);
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/groups") 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) if (offset < 0)
{ {
@ -172,6 +173,23 @@ public class IdentitiesClientImpl extends AbstractIDMClientImpl implements Ident
return this.processEntityBatch(uri, groupProcessor, GroupRepresentation.class); return this.processEntityBatch(uri, groupProcessor, GroupRepresentation.class);
} }
/**
*
* {@inheritDoc}
*/
@Override
public int processSubGroups(final String groupId, final Consumer<GroupRepresentation> 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} * {@inheritDoc}

View File

@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Predicate;
import org.alfresco.repo.security.sync.NodeDescription; import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.namespace.NamespaceService; import org.alfresco.service.namespace.NamespaceService;
@ -37,6 +38,8 @@ public abstract class BaseAttributeProcessor implements InitializingBean
protected NamespaceService namespaceService; protected NamespaceService namespaceService;
protected boolean mapBlankString;
protected boolean mapNull; protected boolean mapNull;
protected Map<String, String> attributes; protected Map<String, String> attributes;
@ -55,14 +58,13 @@ public abstract class BaseAttributeProcessor implements InitializingBean
if (this.attributes != null && !this.attributes.isEmpty()) if (this.attributes != null && !this.attributes.isEmpty())
{ {
this.attributePropertyQNameMappings = new HashMap<>(); this.attributePropertyQNameMappings = new HashMap<>();
this.attributes this.attributes.forEach((k, v) -> this.attributePropertyQNameMappings.put(k, QName.resolveToQName(this.namespaceService, v)));
.forEach((k, v) -> this.attributePropertyQNameMappings.put(k, QName.resolveToQName(this.namespaceService, v)));
} }
} }
/** /**
* @param namespaceService * @param namespaceService
* the namespaceService to set * the namespaceService to set
*/ */
public void setNamespaceService(final NamespaceService namespaceService) public void setNamespaceService(final NamespaceService namespaceService)
{ {
@ -71,16 +73,25 @@ public abstract class BaseAttributeProcessor implements InitializingBean
/** /**
* @param attributes * @param attributes
* the attributes to set * the attributes to set
*/ */
public void setAttributes(final Map<String, String> attributes) public void setAttributes(final Map<String, String> attributes)
{ {
this.attributes = attributes; this.attributes = attributes;
} }
/**
* @param mapBlankString
* the mapBlankString to set
*/
public void setMapBlankString(final boolean mapBlankString)
{
this.mapBlankString = mapBlankString;
}
/** /**
* @param mapNull * @param mapNull
* the mapNull to set * the mapNull to set
*/ */
public void setMapNull(final boolean mapNull) 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. * property, again leaving that kind of processing to the Alfresco default functionality of integrity checking.
* *
* @param attributes * @param attributes
* the list of attributes * the list of attributes
* @param nodeDescription * @param nodeDescription
* the node description to enhance * the node description to enhance
*/ */
protected void map(final Map<String, List<String>> attributes, final NodeDescription nodeDescription) protected void map(final Map<String, List<String>> 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. * Maps an individual attribute to the correlating node property of the node description.
* *
* @param attribute * @param attribute
* the name of the attribute to map * the name of the attribute to map
* @param attributes * @param attributes
* the list of attributes * the list of attributes
* @param nodeDescription * @param nodeDescription
* the node description to enhance * the node description to enhance
*/ */
protected void mapAttribute(final String attribute, final Map<String, List<String>> attributes, final NodeDescription nodeDescription) protected void mapAttribute(final String attribute, final Map<String, List<String>> attributes, final NodeDescription nodeDescription)
{ {
@ -128,12 +139,30 @@ public abstract class BaseAttributeProcessor implements InitializingBean
if (values.size() == 1) if (values.size() == 1)
{ {
value = values.get(0); value = values.get(0);
if (!this.mapBlankString && ((String) value).isBlank())
{
value = null;
}
} }
else 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) else if (this.mapNull)
{ {

View File

@ -42,6 +42,8 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
protected List<String> idResolvedGroupPaths; protected List<String> idResolvedGroupPaths;
protected boolean matchDenies;
protected boolean requireAll = false; protected boolean requireAll = false;
protected boolean allowTransitive = true; protected boolean allowTransitive = true;
@ -75,7 +77,7 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/** /**
* @param groupPaths * @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) public void setGroupPaths(final String groupPaths)
{ {
@ -84,16 +86,25 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/** /**
* @param groupIds * @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) public void setGroupIds(final String groupIds)
{ {
this.groupIds = groupIds != null && !groupIds.isEmpty() ? Arrays.asList(groupIds.split(",")) : null; 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 * @param requireAll
* the requireAll to set * the requireAll to set
*/ */
public void setRequireAll(final boolean requireAll) public void setRequireAll(final boolean requireAll)
{ {
@ -102,7 +113,7 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/** /**
* @param allowTransitive * @param allowTransitive
* the allowTransitive to set * the allowTransitive to set
*/ */
public void setAllowTransitive(final boolean allowTransitive) public void setAllowTransitive(final boolean allowTransitive)
{ {
@ -111,7 +122,7 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
/** /**
* @param groupLoadBatchSize * @param groupLoadBatchSize
* the groupLoadBatchSize to set * the groupLoadBatchSize to set
*/ */
public void setGroupLoadBatchSize(final int groupLoadBatchSize) public void setGroupLoadBatchSize(final int groupLoadBatchSize)
{ {
@ -122,9 +133,9 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
* Checks whether parent groups match the configured restrictions. * Checks whether parent groups match the configured restrictions.
* *
* @param parentGroupIds * @param parentGroupIds
* the list of parent group IDs for an authority * the list of parent group IDs for an authority
* @param parentGroupPaths * @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 * @return {@code true} if the parent groups match the configured restrictions, {@code false} otherwise
*/ */
protected boolean parentGroupsMatch(final List<String> parentGroupIds, final List<String> parentGroupPaths) protected boolean parentGroupsMatch(final List<String> parentGroupIds, final List<String> 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. * Checks whether a specific group path matches any entry in a list of paths using either exact match or prefix matching.
* *
* @param groupPath * @param groupPath
* the path to check * the path to check
* @param groupPaths * @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 * @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<String> groupPaths) protected boolean groupPathOrTransitiveContained(final String groupPath, final Collection<String> groupPaths)

View File

@ -17,7 +17,7 @@ package de.acosix.alfresco.keycloak.repo.sync;
import org.alfresco.model.ContentModel; import org.alfresco.model.ContentModel;
import org.alfresco.repo.security.sync.NodeDescription; 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.alfresco.util.PropertyMap;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;
@ -51,8 +51,19 @@ public class DefaultGroupProcessor implements GroupProcessor
{ {
final PropertyMap properties = groupNode.getProperties(); final PropertyMap properties = groupNode.getProperties();
properties.put(ContentModel.PROP_AUTHORITY_NAME, AuthorityType.GROUP.getPrefixString() + group.getId()); final String existingName = DefaultTypeConverter.INSTANCE.convert(String.class,
properties.put(ContentModel.PROP_AUTHORITY_DISPLAY_NAME, group.getName()); 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());
}
} }
} }
} }

View File

@ -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;
}

View File

@ -15,9 +15,7 @@
*/ */
package de.acosix.alfresco.keycloak.repo.sync; package de.acosix.alfresco.keycloak.repo.sync;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -29,8 +27,7 @@ import org.slf4j.LoggerFactory;
* *
* @author Axel Faust * @author Axel Faust
*/ */
public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter implements GroupFilter
implements GroupFilter
{ {
private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentGroupFilter.class); private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentGroupFilter.class);
@ -40,38 +37,46 @@ public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @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())) if ((this.groupPaths != null && !this.groupPaths.isEmpty()) || (this.groupIds != null && !this.groupIds.isEmpty()))
{ {
LOGGER.debug( LOGGER.debug(
"Checking group {} ({}) for containment in groups with paths {} / IDs {}, using allowTransitive={} and 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); 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 // no need to retrieve parent group ID as path should be sufficient
// Keycloak groups can only ever have one parent // Keycloak groups can only ever have one parent
final List<String> parentGroupIds = Collections.emptyList();
final List<String> parentGroupPaths = new ArrayList<>();
final String groupPath = group.getPath(); final String groupPath = group.getPath();
final String parentPath = groupPath.substring(0, groupPath.lastIndexOf('/')); final String parentPath = groupPath.substring(0, groupPath.lastIndexOf('/'));
if (!parentPath.isEmpty()) 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(), res);
LOGGER.debug("Group containment result for group {}: {}", group.getId(), matches);
} }
else else
{ {
matches = true; res = FilterResult.ABSTAIN;
} }
return matches; return res;
} }
} }

View File

@ -29,8 +29,7 @@ import org.springframework.beans.factory.InitializingBean;
* *
* @author Axel Faust * @author Axel Faust
*/ */
public class GroupContainmentUserFilter extends BaseGroupContainmentFilter public class GroupContainmentUserFilter extends BaseGroupContainmentFilter implements UserFilter, InitializingBean
implements UserFilter, InitializingBean
{ {
private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentUserFilter.class); private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentUserFilter.class);
@ -40,14 +39,15 @@ public class GroupContainmentUserFilter extends BaseGroupContainmentFilter
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @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())) 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={}", LOGGER.debug(
user.getUsername(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll); "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<String> parentGroupIds = new ArrayList<>(); final List<String> parentGroupIds = new ArrayList<>();
final List<String> parentGroupPaths = new ArrayList<>(); final List<String> parentGroupPaths = new ArrayList<>();
@ -63,15 +63,31 @@ public class GroupContainmentUserFilter extends BaseGroupContainmentFilter
offset += processedGroups; 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 else
{ {
matches = true; res = FilterResult.ABSTAIN;
} }
return matches; return res;
} }
} }

View File

@ -31,8 +31,8 @@ public interface GroupFilter
* Determines whether this group should be included in the synchronisation. * Determines whether this group should be included in the synchronisation.
* *
* @param group * @param group
* the group to consider * the group to consider
* @return {@code true} if the group should be synchronised, {@code false} if not * @return the filter result
*/ */
boolean shouldIncludeGroup(GroupRepresentation group); FilterResult shouldIncludeGroup(GroupRepresentation group);
} }

View File

@ -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<String> 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;
}
}

View File

@ -30,10 +30,12 @@ import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.IntConsumer; import java.util.function.IntConsumer;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel; import org.alfresco.model.ContentModel;
import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.sync.NodeDescription; import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.repo.security.sync.UserRegistry; 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.cmr.security.AuthorityType;
import org.alfresco.service.namespace.QName; import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyCheck; import org.alfresco.util.PropertyCheck;
@ -263,22 +265,28 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
*/ */
protected NodeDescription mapGroup(final GroupRepresentation group) 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 NodeDescription groupD = new NodeDescription(group.getId());
final PropertyMap groupProperties = groupD.getProperties(); final PropertyMap groupProperties = groupD.getProperties();
final String groupName = AuthorityType.GROUP.getPrefixString() + group.getId(); LOGGER.debug("Mapping group {} ({})", group.getName(), group.getId());
LOGGER.debug("Mapping group {}", groupName);
this.groupProcessors.forEach(processor -> processor.mapGroup(group, groupD)); 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); groupProperties.put(ContentModel.PROP_AUTHORITY_NAME, groupName);
final Set<String> childAssociations = groupD.getChildAssociations(); final Set<String> childAssociations = groupD.getChildAssociations();
group.getSubGroups().stream() group.getSubGroups().stream().filter(subGroup -> isGroupAllowed(this.groupFilters, subGroup))
.filter(subGroup -> !this.groupFilters.stream().anyMatch(filter -> !filter.shouldIncludeGroup(subGroup)))
.forEach(subGroup -> childAssociations.add(AuthorityType.GROUP.getPrefixString() + subGroup.getId())); .forEach(subGroup -> childAssociations.add(AuthorityType.GROUP.getPrefixString() + subGroup.getId()));
int offset = 0; int offset = 0;
@ -286,8 +294,7 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
while (processedMembers > 0) while (processedMembers > 0)
{ {
processedMembers = this.identitiesClient.processMembers(group.getId(), offset, this.personLoadBatchSize, user -> { processedMembers = this.identitiesClient.processMembers(group.getId(), offset, this.personLoadBatchSize, user -> {
final boolean skipSync = this.userFilters.stream().anyMatch(filter -> !filter.shouldIncludeUser(user)); if (KeycloakUserRegistry.isUserAllowed(this.userFilters, user))
if (!skipSync)
{ {
childAssociations.add(user.getUsername()); 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 // TODO Evaluate other iteration approaches, e.g. crawling from a configured root group
// How to count totals in advance though? // How to count totals in advance though?
return KeycloakUserRegistry.this.identitiesClient.processUsers(offset, batchSize, user -> { return KeycloakUserRegistry.this.identitiesClient.processUsers(offset, batchSize, user -> {
final boolean skipSync = KeycloakUserRegistry.this.userFilters.stream().anyMatch(filter -> !filter.shouldIncludeUser(user)); if (KeycloakUserRegistry.isUserAllowed(KeycloakUserRegistry.this.userFilters, user))
if (!skipSync)
{ {
authorityProcessor.accept(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 // TODO Evaluate other iteration approaches, e.g. crawling from a configured root group
// How to count totals in advance though? // How to count totals in advance though?
return KeycloakUserRegistry.this.identitiesClient.processGroups(offset, batchSize, group -> { final AtomicInteger count = new AtomicInteger();
this.processGroupsRecursively(group, filteredHandler, authorityProcessor); 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, protected void processGroupsRecursively(final GroupRepresentation group, final IntConsumer filteredHandler,
final Consumer<GroupRepresentation> authorityProcessor) final Consumer<GroupRepresentation> authorityProcessor, final AtomicInteger count)
{ {
final boolean skipSync = KeycloakUserRegistry.this.groupFilters.stream().anyMatch(filter -> !filter.shouldIncludeGroup(group)); if (KeycloakUserRegistry.isGroupAllowed(KeycloakUserRegistry.this.groupFilters, group))
if (!skipSync)
{ {
authorityProcessor.accept(group); authorityProcessor.accept(group);
} }
@ -548,9 +555,58 @@ public class KeycloakUserRegistry implements UserRegistry, InitializingBean, Act
filteredHandler.accept(1); filteredHandler.accept(1);
} }
// any filtering applied above does not apply here as any sub-group will be individually checked for filtering by recursive final List<GroupRepresentation> subGroups = group.getSubGroups();
// processing if (subGroups == null || subGroups.isEmpty())
group.getSubGroups().forEach(subGroup -> this.processGroupsRecursively(subGroup, filteredHandler, authorityProcessor)); {
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<UserFilter> 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<GroupFilter> 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;
}
} }

View File

@ -31,8 +31,8 @@ public interface UserFilter
* Determines whether this user should be included in the synchronisation. * Determines whether this user should be included in the synchronisation.
* *
* @param user * @param user
* the user to consider * the user to consider
* @return {@code true} if the user should be synchronised, {@code false} if not * @return the filter result
*/ */
boolean shouldIncludeUser(UserRepresentation user); FilterResult shouldIncludeUser(UserRepresentation user);
} }

View File

@ -194,6 +194,27 @@
<addHeader>false</addHeader> <addHeader>false</addHeader>
</transformer> </transformer>
</transformers> </transformers>
<filters>
<filter>
<artifact>org.keycloak:*</artifact>
<excludes>
<exclude>META-INF/MANIFEST.MF</exclude>
</excludes>
</filter>
<filter>
<artifact>org.jboss.logging:*</artifact>
<excludes>
<exclude>META-INF/MANIFEST.MF</exclude>
</excludes>
</filter>
<filter>
<artifact>com.fasterxml.jackson.core:*</artifact>
<excludes>
<exclude>META-INF/MANIFEST.MF</exclude>
<exclude>META-INF/versions/9/module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>