Skip to content

Commit

Permalink
Namespace support to group-ldap-mapper
Browse files Browse the repository at this point in the history
Previously, Keycloak did only support syncing groups from LDAP federation provider as top-level KC groups.

This approach has some limitations:
- If using multiple group mappers then there’s no way to isolate the KC groups synched by each group mapper.
- If the option "Drop non-existing groups during sync” is activated then all KC groups (including the manually created ones) are deleted.
- There’s no way to inherit roles from a parent KC group.

This patch introduces support to specify a prefix for the resulting group path, which effectively serves as a namespace for a group.

A path prefix can be specified via the newly introduced `Groups Path` config option on the mapper. This groups path defaults to `/` for top-level groups.

This also enables to have multiple `group-ldap-mapper`'s which can manage groups within their own namespace.

An `group-ldap-mapper` with a `Group Path` configured as `/Applications/App1` will only manage groups under that path. Other groups, either manually created or managed by other `group-ldap-mapper` are not affected.
  • Loading branch information
tjuerge authored and mposolda committed May 26, 2020
1 parent f15821f commit 6005503
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 43,7 @@
import org.keycloak.storage.ldap.mappers.membership.UserRolesRetrieveStrategy;
import org.keycloak.storage.user.SynchronizationResult;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
Expand Down Expand Up @@ -213,12 214,11 @@ private void syncNonExistingGroup(RealmModel realm, Map.Entry<String, LDAPObject
SynchronizationResult syncResult, Set<String> visitedGroupIds, String groupName) {
try {
// Create each non-existing group to be synced in its own inner transaction to prevent race condition when
// the roup intended to be created was already created via other channel in the meantime
// the group intended to be created was already created via other channel in the meantime
KeycloakModelUtils.runJobInTransaction(ldapProvider.getSession().getKeycloakSessionFactory(), session -> {
RealmModel innerTransactionRealm = session.realms().getRealm(realm.getId());
GroupModel kcGroup = innerTransactionRealm.createGroup(groupName);
GroupModel kcGroup = createKcGroup(innerTransactionRealm, groupName, null);
updateAttributesOfKCGroup(kcGroup, groupEntry.getValue());
innerTransactionRealm.moveGroup(kcGroup, null);
syncResult.increaseAdded();
visitedGroupIds.add(kcGroup.getId());
});
Expand Down Expand Up @@ -252,7 252,7 @@ private void convertGroupsToInternalRep(List<LDAPObject> ldapGroups, Map<String,
private void syncFlatGroupStructure(RealmModel realm, SynchronizationResult syncResult, Map<String, LDAPObject> ldapGroupsMap) {
Set<String> visitedGroupIds = new HashSet<>();

// Just add flat structure of groups with all groups at top-level
// Just add flat structure of groups with all groups at groups path
LDAPConfig ldapConfig = ldapProvider.getLdapIdentityStore().getConfig();
final int groupsPerTransaction = ldapConfig.getBatchSizeForSync();
Set<Map.Entry<String, LDAPObject>> entries = ldapGroupsMap.entrySet();
Expand All @@ -266,16 266,16 @@ private void syncFlatGroupStructure(RealmModel realm, SynchronizationResult sync
// due to the realm cache being bloated with huge amount of (temporary) realm entities
RealmModel currentRealm = session.realms().getRealm(realm.getId());

// List of top-level groups known to the whole transaction
Map<String, GroupModel> transactionTopLevelGroups = currentRealm.getTopLevelGroups()
// List of group path groups known to the whole transaction
Map<String, GroupModel> transactionGroupPathGroups = getKcSubGroups(currentRealm, null)
.stream()
.collect(Collectors.toMap(GroupModel::getName, Function.identity()));

for (int i = 0; i < groupsPerTransaction && it.hasNext(); i ) {
Map.Entry<String, LDAPObject> groupEntry = it.next();

String groupName = groupEntry.getKey();
GroupModel kcExistingGroup = transactionTopLevelGroups.get(groupName);
GroupModel kcExistingGroup = transactionGroupPathGroups.get(groupName);

if (kcExistingGroup != null) {
syncExistingGroup(kcExistingGroup, groupEntry, syncResult, visitedGroupIds, groupName);
Expand Down Expand Up @@ -310,7 310,7 @@ private void updateKeycloakGroupTreeEntry(RealmModel realm, GroupTreeResolver.Gr

// Check if group already exists
GroupModel kcGroup = null;
Collection<GroupModel> subgroups = kcParent == null ? realm.getTopLevelGroups() : kcParent.getSubGroups();
Collection<GroupModel> subgroups = getKcSubGroups(realm, kcParent);
for (GroupModel group : subgroups) {
if (group.getName().equals(groupName)) {
kcGroup = group;
Expand All @@ -323,12 323,11 @@ private void updateKeycloakGroupTreeEntry(RealmModel realm, GroupTreeResolver.Gr
updateAttributesOfKCGroup(kcGroup, ldapGroups.get(kcGroup.getName()));
syncResult.increaseUpdated();
} else {
if (kcParent == null) {
kcGroup = realm.createGroup(groupTreeEntry.getGroupName());
kcGroup = createKcGroup(realm, groupTreeEntry.getGroupName(), kcParent);
if (kcGroup.getParent() == null) {
logger.debugf("Imported top-level group '%s' from LDAP", kcGroup.getName());
} else {
kcGroup = realm.createGroup(groupTreeEntry.getGroupName(), kcParent);
logger.debugf("Imported group '%s' from LDAP as child of group '%s'", kcGroup.getName(), kcParent.getName());
logger.debugf("Imported group '%s' from LDAP as child of group '%s'", kcGroup.getName(), kcGroup.getParent().getName());
}

updateAttributesOfKCGroup(kcGroup, ldapGroups.get(kcGroup.getName()));
Expand All @@ -344,7 343,7 @@ private void updateKeycloakGroupTreeEntry(RealmModel realm, GroupTreeResolver.Gr

private void dropNonExistingKcGroups(RealmModel realm, SynchronizationResult syncResult, Set<String> visitedGroupIds) {
// Remove keycloak groups, which doesn't exists in LDAP
List<GroupModel> allGroups = realm.getGroups();
List<GroupModel> allGroups = getAllKcGroups(realm);
for (GroupModel kcGroup : allGroups) {
if (!visitedGroupIds.contains(kcGroup.getId())) {
logger.debugf("Removing Keycloak group '%s', which doesn't exist in LDAP", kcGroup.getName());
Expand Down Expand Up @@ -374,7 373,7 @@ protected GroupModel findKcGroupByLDAPGroup(RealmModel realm, LDAPObject ldapGro

if (config.isPreserveGroupsInheritance()) {
// Override if better effectivity or different algorithm is needed
List<GroupModel> groups = realm.getGroups();
List<GroupModel> groups = getAllKcGroups(realm);
for (GroupModel group : groups) {
if (group.getName().equals(groupName)) {
return group;
Expand All @@ -383,8 382,8 @@ protected GroupModel findKcGroupByLDAPGroup(RealmModel realm, LDAPObject ldapGro

return null;
} else {
// Without preserved inheritance, it's always top-level group
return KeycloakModelUtils.findGroupByPath(realm, "/" groupName);
// Without preserved inheritance, it's always at groups path
return KeycloakModelUtils.findGroupByPath(realm, getKcGroupPathFromLDAPGroupName(groupName));
}
}

Expand All @@ -404,7 403,7 @@ protected GroupModel findKcGroupOrSyncFromLDAP(RealmModel realm, LDAPObject ldap
String groupNameAttr = config.getGroupNameLdapAttribute();
String groupName = ldapGroup.getAttributeAsString(groupNameAttr);

kcGroup = realm.createGroup(groupName);
kcGroup = createKcGroup(realm, groupName, null);
updateAttributesOfKCGroup(kcGroup, ldapGroup);
}

Expand Down Expand Up @@ -462,7 461,7 @@ public String getStatus() {
Set<String> ldapGroupNames = new HashSet<>();

// Create or update KC groups to LDAP including their attributes
for (GroupModel kcGroup : realm.getTopLevelGroups()) {
for (GroupModel kcGroup : getKcSubGroups(realm, null)) {
processKeycloakGroupSyncToLDAP(kcGroup, ldapGroupsMap, ldapGroupNames, syncResult);
}

Expand All @@ -480,7 479,7 @@ public String getStatus() {

// Finally process memberships,
if (config.isPreserveGroupsInheritance()) {
for (GroupModel kcGroup : realm.getTopLevelGroups()) {
for (GroupModel kcGroup : getKcSubGroups(realm, null)) {
processKeycloakGroupMembershipsSyncToLDAP(kcGroup, ldapGroupsMap);
}
}
Expand Down Expand Up @@ -556,9 555,9 @@ private void processKeycloakGroupMembershipsSyncToLDAP(GroupModel kcGroup, Map<S

// Recursively check if parent group exists in LDAP. If yes, then return current group. If not, then recursively call this method
// for the predecessor. Result is the highest group, which doesn't yet exists in LDAP (and hence requires sync to LDAP)
private GroupModel getHighestPredecessorNotExistentInLdap(GroupModel group) {
private GroupModel getHighestPredecessorNotExistentInLdap(GroupModel groupsPathGroup, GroupModel group) {
GroupModel parentGroup = group.getParent();
if (parentGroup == null) {
if (parentGroup == groupsPathGroup) {
return group;
}

Expand All @@ -568,7 567,7 @@ private GroupModel getHighestPredecessorNotExistentInLdap(GroupModel group) {
return group;
} else {
// Parent doesn't exists in LDAP. Let's recursively go up.
return getHighestPredecessorNotExistentInLdap(parentGroup);
return getHighestPredecessorNotExistentInLdap(groupsPathGroup, parentGroup);
}
}

Expand Down Expand Up @@ -600,7 599,8 @@ public void addGroupMappingInLDAP(RealmModel realm, GroupModel kcGroup, LDAPObje
if (ldapGroup == null) {
// Needs to partially sync Keycloak groups to LDAP
if (config.isPreserveGroupsInheritance()) {
GroupModel highestGroupToSync = getHighestPredecessorNotExistentInLdap(kcGroup);
GroupModel groupsPathGroup = getKcGroupsPathGroup(realm);
GroupModel highestGroupToSync = getHighestPredecessorNotExistentInLdap(groupsPathGroup, kcGroup);

logger.debugf("Will sync group '%s' and it's subgroups from DB to LDAP", highestGroupToSync.getName());

Expand All @@ -611,7 611,7 @@ public void addGroupMappingInLDAP(RealmModel realm, GroupModel kcGroup, LDAPObje
ldapGroup = loadLDAPGroupByName(groupName);

// Finally update LDAP membership in the parent group
if (highestGroupToSync.getParent() != null) {
if (highestGroupToSync.getParent() != groupsPathGroup) {
LDAPObject ldapParentGroup = loadLDAPGroupByName(highestGroupToSync.getParent().getName());
LDAPUtils.addMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), getMembershipUserLdapAttribute(), ldapParentGroup, ldapGroup);
}
Expand Down Expand Up @@ -793,4 793,62 @@ protected Set<GroupModel> getLDAPGroupMappingsConverted() {
return result;
}
}

// LDAP groups path operations

/**
* Translates given LDAP group name into a KC group within the groups path.
*/
protected String getKcGroupPathFromLDAPGroupName(String ldapGroupName) {
return config.getGroupsPathWithTrailingSlash() ldapGroupName;
}

/**
* Provides KC group defined as groups path or null (top-level group) if corresponding group is not available.
*/
protected GroupModel getKcGroupsPathGroup(RealmModel realm) {
return config.isTopLevelGroupsPath() ? null : KeycloakModelUtils.findGroupByPath(realm, config.getGroupsPath());
}

/**
* Creates a new KC group from given LDAP group name in given KC parent group or the groups path.
*/
protected GroupModel createKcGroup(RealmModel realm, String ldapGroupName, GroupModel parentGroup) {

// If no parent group given then use groups path
if (parentGroup == null) {
parentGroup = getKcGroupsPathGroup(realm);
}
return realm.createGroup(ldapGroupName, parentGroup);
}

/**
* Provides a list of all KC sub groups from given parent group or from groups path.
*/
protected Collection<GroupModel> getKcSubGroups(RealmModel realm, GroupModel parentGroup) {

// If no parent group given then use groups path
if (parentGroup == null) {
parentGroup = getKcGroupsPathGroup(realm);
}
return parentGroup == null ? realm.getTopLevelGroups() : parentGroup.getSubGroups();
}

/**
* Provides a list of all KC groups (with their sub groups) from groups path.
*/
protected List<GroupModel> getAllKcGroups(RealmModel realm) {
List<GroupModel> allGroups = new ArrayList<>();
for (GroupModel group : getKcSubGroups(realm, null)) {
addGroupAndSubGroups(group, allGroups);
}
return allGroups;
}

private void addGroupAndSubGroups(GroupModel group, List<GroupModel> allGroups) {
allGroups.add(group);
for (GroupModel subGroup : group.getSubGroups()) {
addGroupAndSubGroups(subGroup, allGroups);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 22,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.LDAPConstants;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.UserStorageProvider;
Expand Down Expand Up @@ -207,6 208,15 @@ private static List<ProviderConfigProperty> getProps(ComponentModel parent) {
.helpText("If this flag is true, then during sync of groups from LDAP to Keycloak, we will keep just those Keycloak groups, which still exists in LDAP. Rest will be deleted")
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("false")
.add()
.property().name(GroupMapperConfig.LDAP_GROUPS_PATH)
.label("Groups Path")
.helpText("Keycloak group path the LDAP groups are added to. For example if value '/Applications/App1' is used, "
"then LDAP groups will be available in Keycloak under group 'App1', which is child of top level group 'Applications'. "
"The default value is '/' so LDAP groups will be mapped to the Keycloak groups at the top level. "
"The configured group path must already exists in the Keycloak when creating this mapper.")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("/")
.add();
return config.build();
}
Expand Down Expand Up @@ -282,6 292,12 @@ public void validateConfiguration(KeycloakSession session, RealmModel realm, Com
}

LDAPUtils.validateCustomLdapFilter(config.getConfig().getFirst(GroupMapperConfig.GROUPS_LDAP_FILTER));

checkMandatoryConfigAttribute(GroupMapperConfig.LDAP_GROUPS_PATH, "Groups Path", config);
String group = config.getConfig().getFirst(GroupMapperConfig.LDAP_GROUPS_PATH).trim();
if (!"/".equals(group) && KeycloakModelUtils.findGroupByPath(realm, group) == null) {
throw new ComponentValidationException("ldapErrorMissingGroupsPathGroup");
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 61,9 @@ public class GroupMapperConfig extends CommonLDAPGroupMapperConfig {
public static final String GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE = "GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE";
public static final String LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY = "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY";

// Keycloak group path the LDAP groups are added to (default: top level "/")
public static final String LDAP_GROUPS_PATH = "groups.path";

public GroupMapperConfig(ComponentModel mapperModel) {
super(mapperModel);
}
Expand Down Expand Up @@ -124,4 127,20 @@ public String getUserGroupsRetrieveStrategy() {
String strategyString = mapperModel.getConfig().getFirst(USER_ROLES_RETRIEVE_STRATEGY);
return strategyString!=null ? strategyString : LOAD_GROUPS_BY_MEMBER_ATTRIBUTE;
}

public String getGroupsPath() {
return mapperModel.getConfig().getFirst(LDAP_GROUPS_PATH);
}

public String getGroupsPathWithTrailingSlash() {
String path = getGroupsPath();
while (!path.endsWith("/")) {
path = getGroupsPath() "/";
}
return path;
}

public boolean isTopLevelGroupsPath() {
return "/".equals(getGroupsPath());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 25,6 @@
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.UserModelDelegate;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.ldap.LDAPStorageProvider;
import org.keycloak.storage.ldap.LDAPConfig;
Expand Down Expand Up @@ -198,7 197,8 @@ public static void addOrUpdateGroupMapper(RealmModel realm, ComponentModel provi
GroupMapperConfig.GROUPS_DN, "ou=Groups," baseDn,
GroupMapperConfig.MAPPED_GROUP_ATTRIBUTES, descriptionAttrName,
GroupMapperConfig.PRESERVE_GROUP_INHERITANCE, "true",
GroupMapperConfig.MODE, mode.toString());
GroupMapperConfig.MODE, mode.toString(),
GroupMapperConfig.LDAP_GROUPS_PATH, "/");
updateGroupMapperConfigOptions(mapperModel, otherConfigOptions);
realm.addComponentModel(mapperModel);
}
Expand Down
Loading

0 comments on commit 6005503

Please sign in to comment.