mirror of
https://github.com/bmlong137/alfresco-keycloak.git
synced 2025-05-12 21:24:43 +00:00
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:
parent
725f768535
commit
4b1b0cbd08
@ -1162,6 +1162,10 @@
|
||||
"groups": [
|
||||
{
|
||||
"name": "Test A",
|
||||
"attributes": {
|
||||
"alfrescoGroupName": ["Group_A"],
|
||||
"alfrescoGroupDisplayName": ["Group with custom mapped attributes"]
|
||||
},
|
||||
"subGroups": [
|
||||
{
|
||||
"name": "Test AA"
|
||||
|
@ -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
|
2
pom.xml
2
pom.xml
@ -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>
|
||||
|
@ -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-*
|
@ -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>
|
||||
|
@ -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
|
@ -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.
|
||||
*
|
||||
|
@ -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}
|
||||
|
@ -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,8 +58,7 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,6 +80,15 @@ public abstract class BaseAttributeProcessor implements InitializingBean
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mapBlankString
|
||||
* the mapBlankString to set
|
||||
*/
|
||||
public void setMapBlankString(final boolean mapBlankString)
|
||||
{
|
||||
this.mapBlankString = mapBlankString;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mapNull
|
||||
* the mapNull to set
|
||||
@ -128,13 +139,31 @@ public abstract class BaseAttributeProcessor implements InitializingBean
|
||||
if (values.size() == 1)
|
||||
{
|
||||
value = values.get(0);
|
||||
if (!this.mapBlankString && ((String) value).isBlank())
|
||||
{
|
||||
value = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
else if (this.mapNull)
|
||||
{
|
||||
nodeDescription.getProperties().put(propertyQName, null);
|
||||
|
@ -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;
|
||||
@ -91,6 +93,15 @@ public abstract class BaseGroupContainmentFilter implements InitializingBean
|
||||
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
|
||||
|
@ -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());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
matches = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
|
||||
|
||||
LOGGER.debug("Group containment result for group {}: {}", group.getId(), matches);
|
||||
final boolean parentGroupsMatch = this.parentGroupsMatch(Collections.emptyList(), Collections.singletonList(parentPath));
|
||||
if (this.matchDenies)
|
||||
{
|
||||
res = parentGroupsMatch ? FilterResult.DENY : FilterResult.ABSTAIN;
|
||||
}
|
||||
else
|
||||
{
|
||||
matches = true;
|
||||
res = parentGroupsMatch ? FilterResult.ALLOW : FilterResult.ABSTAIN;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// no parents to check
|
||||
res = FilterResult.ABSTAIN;
|
||||
}
|
||||
|
||||
return matches;
|
||||
LOGGER.debug("Group containment result for group {}: {}", group.getId(), res);
|
||||
}
|
||||
else
|
||||
{
|
||||
res = FilterResult.ABSTAIN;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
LOGGER.debug("Group containment result for user {}: {}", user.getUsername(), matches);
|
||||
if (parentGroupIds.isEmpty() || parentGroupPaths.isEmpty())
|
||||
{
|
||||
final boolean parentGroupsMatch = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
|
||||
if (this.matchDenies)
|
||||
{
|
||||
res = parentGroupsMatch ? FilterResult.DENY : FilterResult.ABSTAIN;
|
||||
}
|
||||
else
|
||||
{
|
||||
matches = true;
|
||||
res = parentGroupsMatch ? FilterResult.ALLOW : FilterResult.ABSTAIN;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// no parents to check
|
||||
res = FilterResult.ABSTAIN;
|
||||
}
|
||||
|
||||
return matches;
|
||||
LOGGER.debug("Group containment result for user {}: {}", user.getUsername(), res);
|
||||
}
|
||||
else
|
||||
{
|
||||
res = FilterResult.ABSTAIN;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ public interface GroupFilter
|
||||
*
|
||||
* @param group
|
||||
* 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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ public interface UserFilter
|
||||
*
|
||||
* @param user
|
||||
* 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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user