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