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": [
{
"name": "Test A",
"attributes": {
"alfrescoGroupName": ["Group_A"],
"alfrescoGroupDisplayName": ["Group with custom mapped attributes"]
},
"subGroups": [
{
"name": "Test AA"

View File

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

View File

@ -88,7 +88,7 @@
<!-- parent is including 6.11 erroneously -->
<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>
<keycloak.docker.image>keycloak/keycloak</keycloak.docker.image>

View File

@ -5,4 +5,4 @@ module.version=${noSnapshotVersion}
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" />
</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">
<property name="identitiesClient" ref="identitiesClient" />
</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">
<property name="namespaceService" ref="namespaceService" />
</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.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
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);
/**
* 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.
*

View File

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

View File

@ -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<String, String> 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<String, String> 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<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.
*
* @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<String, List<String>> 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)
{

View File

@ -42,6 +42,8 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
protected List<String> 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<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.
*
* @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<String> groupPaths)

View File

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

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;
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<String> parentGroupIds = Collections.emptyList();
final List<String> 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;
}
}

View File

@ -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<String> parentGroupIds = new ArrayList<>();
final List<String> 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;
}
}

View File

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

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.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<String> 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<GroupRepresentation> authorityProcessor)
final Consumer<GroupRepresentation> 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<GroupRepresentation> 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<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.
*
* @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);
}

View File

@ -194,6 +194,27 @@
<addHeader>false</addHeader>
</transformer>
</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>
</execution>
</executions>