KEYCLOAK-4640: LDAP memberships are being replaced instead of being added or deleted

This commit is contained in:
rmartinc 2019-02-25 18:05:44 +01:00 committed by Hynek Mlnařík
parent 996389d61b
commit 2602c222cd
13 changed files with 592 additions and 101 deletions

View file

@ -38,6 +38,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.naming.directory.SearchControls;
/**
* Allow to directly call some operations against LDAPIdentityStore.
@ -167,30 +168,10 @@ public class LDAPUtils {
* @param memberChildAttrName used just if membershipType is UID. Usually 'uid'
* @param ldapParent role or group
* @param ldapChild usually user (or child group or child role)
* @param sendLDAPUpdateRequest if true, the method will send LDAP update request too. Otherwise it will skip it
*/
public static void addMember(LDAPStorageProvider ldapProvider, MembershipType membershipType, String memberAttrName, String memberChildAttrName, LDAPObject ldapParent, LDAPObject ldapChild, boolean sendLDAPUpdateRequest) {
Set<String> memberships = getExistingMemberships(memberAttrName, ldapParent);
// Remove membership placeholder if present
if (membershipType == MembershipType.DN) {
for (String membership : memberships) {
if (LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE.equals(membership)) {
memberships.remove(membership);
break;
}
}
}
public static void addMember(LDAPStorageProvider ldapProvider, MembershipType membershipType, String memberAttrName, String memberChildAttrName, LDAPObject ldapParent, LDAPObject ldapChild) {
String membership = getMemberValueOfChildObject(ldapChild, membershipType, memberChildAttrName);
memberships.add(membership);
ldapParent.setAttribute(memberAttrName, memberships);
if (sendLDAPUpdateRequest) {
ldapProvider.getLdapIdentityStore().update(ldapParent);
}
ldapProvider.getLdapIdentityStore().addMemberToGroup(ldapParent.getDn().toString(), memberAttrName, membership);
}
/**
@ -204,29 +185,20 @@ public class LDAPUtils {
* @param ldapChild usually user (or child group or child role)
*/
public static void deleteMember(LDAPStorageProvider ldapProvider, MembershipType membershipType, String memberAttrName, String memberChildAttrName, LDAPObject ldapParent, LDAPObject ldapChild) {
Set<String> memberships = getExistingMemberships(memberAttrName, ldapParent);
String userMembership = getMemberValueOfChildObject(ldapChild, membershipType, memberChildAttrName);
memberships.remove(userMembership);
// Some membership placeholder needs to be always here as "member" is mandatory attribute on some LDAP servers. But not on active directory! (Placeholder, which not matches any real object is not allowed here)
if (memberships.size() == 0 && membershipType== MembershipType.DN && !ldapProvider.getLdapIdentityStore().getConfig().isActiveDirectory()) {
memberships.add(LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE);
}
ldapParent.setAttribute(memberAttrName, memberships);
ldapProvider.getLdapIdentityStore().update(ldapParent);
ldapProvider.getLdapIdentityStore().removeMemberFromGroup(ldapParent.getDn().toString(), memberAttrName, userMembership);
}
/**
* Return all existing memberships (values of attribute 'member' ) from the given ldapRole or ldapGroup
*
* @param ldapProvider The ldap provider
* @param memberAttrName usually 'member'
* @param ldapRole
* @return
*/
public static Set<String> getExistingMemberships(String memberAttrName, LDAPObject ldapRole) {
public static Set<String> getExistingMemberships(LDAPStorageProvider ldapProvider, String memberAttrName, LDAPObject ldapRole) {
LDAPUtils.fillRangedAttribute(ldapProvider, ldapRole, memberAttrName);
Set<String> memberships = ldapRole.getAttributeAsSet(memberAttrName);
if (memberships == null) {
memberships = new HashSet<>();
@ -298,4 +270,27 @@ public class LDAPUtils {
}
}
}
private static LDAPQuery createLdapQueryForRangeAttribute(LDAPStorageProvider ldapProvider, LDAPObject ldapObject, String name) {
LDAPQuery q = new LDAPQuery(ldapProvider);
q.setSearchDn(ldapObject.getDn().toString());
q.setSearchScope(SearchControls.OBJECT_SCOPE);
q.addReturningLdapAttribute(name + ";range=" + (ldapObject.getCurrentRange(name) + 1) + "-*");
return q;
}
/**
* Performs iterative searches over an LDAPObject to return an attribute that is ranged.
* @param ldapProvider The provider to use
* @param ldapObject The current object with the ranged attribute not complete
* @param name The attribute name
*/
public static void fillRangedAttribute(LDAPStorageProvider ldapProvider, LDAPObject ldapObject, String name) {
LDAPObject newObject = ldapObject;
while (!newObject.isRangeComplete(name)) {
LDAPQuery q = createLdapQueryForRangeAttribute(ldapProvider, ldapObject, name);
newObject = q.getFirstResult();
ldapObject.populateRangedAttribute(newObject, name);
}
}
}

View file

@ -48,6 +48,8 @@ public class LDAPObject {
// Copy of "attributes" containing lower-cased keys
private final Map<String, Set<String>> lowerCasedAttributes = new HashMap<>();
// range attributes are always read from 0 to max so just saving the top value
private final Map<String, Integer> rangedAttributes = new HashMap<>();
public String getUuid() {
return uuid;
@ -123,6 +125,36 @@ public class LDAPObject {
return (values == null) ? null : new LinkedHashSet<>(values);
}
public boolean isRangeComplete(String name) {
return !rangedAttributes.containsKey(name);
}
public int getCurrentRange(String name) {
return rangedAttributes.get(name);
}
public boolean isRangeCompleteForAllAttributes() {
return rangedAttributes.isEmpty();
}
public void addRangedAttribute(String name, int max) {
Integer current = rangedAttributes.get(name);
if (current == null || max > current) {
rangedAttributes.put(name, max);
}
}
public void populateRangedAttribute(LDAPObject obj, String name) {
Set<String> newValues = obj.getAttributes().get(name);
if (newValues != null && attributes.containsKey(name)) {
attributes.get(name).addAll(newValues);
if (!obj.isRangeComplete(name)) {
addRangedAttribute(name, obj.getCurrentRange(name));
} else {
rangedAttributes.remove(name);
}
}
}
public Map<String, Set<String>> getAttributes() {
return attributes;
@ -152,6 +184,7 @@ public class LDAPObject {
@Override
public String toString() {
return "LDAP Object [ dn: " + dn + " , uuid: " + uuid + ", attributes: " + attributes + ", readOnly attribute names: " + readOnlyAttributeNames + " ]";
return "LDAP Object [ dn: " + dn + " , uuid: " + uuid + ", attributes: " + attributes +
", readOnly attribute names: " + readOnlyAttributeNames + ", ranges: " + rangedAttributes + " ]";
}
}

View file

@ -65,6 +65,22 @@ public interface IdentityStore {
*/
void remove(LDAPObject ldapObject);
/**
* Adds a member to a group.
* @param groupDn The DN of the group object
* @param memberAttrName The member attribute name
* @param value The value (it can be uid or dn depending the group type)
*/
public void addMemberToGroup(String groupDn, String memberAttrName, String value);
/**
* Removes a member from a group.
* @param groupDn The DN of the group object
* @param memberAttrName The member attribute name
* @param value The value (it can be uid or dn depending the group type)
*/
public void removeMemberFromGroup(String groupDn, String memberAttrName, String value);
// Identity query
List<LDAPObject> fetchQueryResults(LDAPQuery LDAPQuery);

View file

@ -54,6 +54,11 @@ import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.naming.directory.AttributeInUseException;
import javax.naming.directory.NoSuchAttributeException;
import javax.naming.directory.SchemaViolationException;
/**
* An IdentityStore implementation backed by an LDAP directory
@ -65,6 +70,7 @@ import java.util.TreeSet;
public class LDAPIdentityStore implements IdentityStore {
private static final Logger logger = Logger.getLogger(LDAPIdentityStore.class);
private static final Pattern rangePattern = Pattern.compile("([^;]+);range=([0-9]+)-([0-9]+|\\*)");
private final LDAPConfig config;
private final LDAPOperationManager operationManager;
@ -101,6 +107,44 @@ public class LDAPIdentityStore implements IdentityStore {
}
}
@Override
public void addMemberToGroup(String groupDn, String memberAttrName, String value) {
// do not check EMPTY_MEMBER_ATTRIBUTE_VALUE, we save one useless query
// the value will be there forever for objectclasses that enforces the attribute as MUST
BasicAttribute attr = new BasicAttribute(memberAttrName, value);
ModificationItem item = new ModificationItem(DirContext.ADD_ATTRIBUTE, attr);
try {
this.operationManager.modifyAttributesNaming(groupDn, new ModificationItem[]{item}, null);
} catch (AttributeInUseException e) {
logger.debugf("Group %s already contains the member %s", groupDn, value);
} catch (NamingException e) {
throw new ModelException("Could not modify attribute for DN [" + groupDn + "]", e);
}
}
@Override
public void removeMemberFromGroup(String groupDn, String memberAttrName, String value) {
BasicAttribute attr = new BasicAttribute(memberAttrName, value);
ModificationItem item = new ModificationItem(DirContext.REMOVE_ATTRIBUTE, attr);
try {
this.operationManager.modifyAttributesNaming(groupDn, new ModificationItem[]{item}, null);
} catch (NoSuchAttributeException e) {
logger.debugf("Group %s does not contain the member %s", groupDn, value);
} catch (SchemaViolationException e) {
// schema violation removing one member => add the empty attribute, it cannot be other thing
logger.infof("Schema violation in group %s removing member %s. Trying adding empty member attribute.", groupDn, value);
try {
this.operationManager.modifyAttributesNaming(groupDn,
new ModificationItem[]{item, new ModificationItem(DirContext.ADD_ATTRIBUTE, new BasicAttribute(memberAttrName, LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE))},
null);
} catch (NamingException ex) {
throw new ModelException("Could not modify attribute for DN [" + groupDn + "]", ex);
}
} catch (NamingException e) {
throw new ModelException("Could not modify attribute for DN [" + groupDn + "]", e);
}
}
@Override
public void update(LDAPObject ldapObject) {
checkRename(ldapObject);
@ -199,7 +243,8 @@ public class LDAPIdentityStore implements IdentityStore {
}
for (SearchResult result : search) {
if (!result.getNameInNamespace().equalsIgnoreCase(baseDN)) {
// don't add the branch in subtree search
if (identityQuery.getSearchScope() != SearchControls.SUBTREE_SCOPE || !result.getNameInNamespace().equalsIgnoreCase(baseDN)) {
results.add(populateAttributedType(result, identityQuery));
}
}
@ -351,6 +396,21 @@ public class LDAPIdentityStore implements IdentityStore {
String ldapAttributeName = ldapAttribute.getID();
// check for ranged attribute
Matcher m = rangePattern.matcher(ldapAttributeName);
if (m.matches()) {
ldapAttributeName = m.group(1);
// range=X-* means all the attributes returned
if (!m.group(3).equals("*")) {
try {
int max = Integer.parseInt(m.group(3));
ldapObject.addRangedAttribute(ldapAttributeName, max);
} catch (NumberFormatException e) {
logger.warnf("Invalid ranged expresion for attribute: %s", m.group(0));
}
}
}
if (ldapAttributeName.equalsIgnoreCase(getConfig().getUuidLDAPAttributeName())) {
Object uuidValue = ldapAttribute.get();
ldapObject.setUuid(this.operationManager.decodeEntryUUID(uuidValue));

View file

@ -528,8 +528,7 @@ public class LDAPOperationManager {
}
}
public void modifyAttributes(final String dn, final ModificationItem[] mods, LDAPOperationDecorator decorator) {
try {
public void modifyAttributesNaming(final String dn, final ModificationItem[] mods, LDAPOperationDecorator decorator) throws NamingException {
if (logger.isTraceEnabled()) {
logger.tracef("Modifying attributes for entry [%s]: [", dn);
@ -561,7 +560,6 @@ public class LDAPOperationManager {
return null;
}
@Override
public String toString() {
return new StringBuilder("LdapOperation: modify\n")
@ -570,8 +568,12 @@ public class LDAPOperationManager {
.toString();
}
}, null, decorator);
}
public void modifyAttributes(final String dn, final ModificationItem[] mods, LDAPOperationDecorator decorator) {
try {
modifyAttributesNaming(dn, mods, decorator);
} catch (NamingException e) {
throw new ModelException("Could not modify attribute for DN [" + dn + "]", e);
}

View file

@ -50,12 +50,12 @@ public enum MembershipType {
@Override
public Set<LDAPDn> getLDAPSubgroups(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup) {
CommonLDAPGroupMapperConfig config = groupMapper.getConfig();
return getLDAPMembersWithParent(ldapGroup, config.getMembershipLdapAttribute(), LDAPDn.fromString(config.getLDAPGroupsDn()));
return getLDAPMembersWithParent(groupMapper.getLdapProvider(), ldapGroup, config.getMembershipLdapAttribute(), LDAPDn.fromString(config.getLDAPGroupsDn()));
}
// Get just those members of specified group, which are descendants of "requiredParentDn"
protected Set<LDAPDn> getLDAPMembersWithParent(LDAPObject ldapGroup, String membershipLdapAttribute, LDAPDn requiredParentDn) {
Set<String> allMemberships = LDAPUtils.getExistingMemberships(membershipLdapAttribute, ldapGroup);
protected Set<LDAPDn> getLDAPMembersWithParent(LDAPStorageProvider ldapProvider, LDAPObject ldapGroup, String membershipLdapAttribute, LDAPDn requiredParentDn) {
Set<String> allMemberships = LDAPUtils.getExistingMemberships(ldapProvider, membershipLdapAttribute, ldapGroup);
// Filter and keep just descendants of requiredParentDn
Set<LDAPDn> result = new HashSet<>();
@ -74,7 +74,7 @@ public enum MembershipType {
CommonLDAPGroupMapperConfig config = groupMapper.getConfig();
LDAPDn usersDn = LDAPDn.fromString(ldapProvider.getLdapIdentityStore().getConfig().getUsersDn());
Set<LDAPDn> userDns = getLDAPMembersWithParent(ldapGroup, config.getMembershipLdapAttribute(), usersDn);
Set<LDAPDn> userDns = getLDAPMembersWithParent(ldapProvider, ldapGroup, config.getMembershipLdapAttribute(), usersDn);
if (userDns == null) {
return Collections.emptyList();
@ -139,7 +139,7 @@ public enum MembershipType {
LDAPConfig ldapConfig = ldapProvider.getLdapIdentityStore().getConfig();
String memberAttrName = groupMapper.getConfig().getMembershipLdapAttribute();
Set<String> memberUids = LDAPUtils.getExistingMemberships(memberAttrName, ldapGroup);
Set<String> memberUids = LDAPUtils.getExistingMemberships(ldapProvider, memberAttrName, ldapGroup);
if (memberUids == null || memberUids.size() <= firstResult) {
return Collections.emptyList();

View file

@ -465,8 +465,10 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
Set<GroupModel> kcSubgroups = kcGroup.getSubGroups();
for (GroupModel kcSubgroup : kcSubgroups) {
LDAPObject ldapSubgroup = ldapGroupsMap.get(kcSubgroup.getName());
LDAPUtils.addMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), membershipUserLdapAttrName, ldapGroup, ldapSubgroup, false);
toRemoveSubgroupsDNs.remove(ldapSubgroup.getDn());
if (!toRemoveSubgroupsDNs.remove(ldapSubgroup.getDn())) {
// if the group is not in the ldap group => add it
LDAPUtils.addMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), membershipUserLdapAttrName, ldapGroup, ldapSubgroup);
}
}
// Remove LDAP subgroups, which are not members in KC anymore
@ -476,11 +478,6 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
LDAPUtils.deleteMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), membershipUserLdapAttrName, ldapGroup, fakeGroup);
}
// Update group to LDAP
if (!kcGroup.getSubGroups().isEmpty() || !toRemoveSubgroupsDNs.isEmpty()) {
ldapProvider.getLdapIdentityStore().update(ldapGroup);
}
for (GroupModel kcSubgroup : kcGroup.getSubGroups()) {
processKeycloakGroupMembershipsSyncToLDAP(kcSubgroup, ldapGroupsMap);
}
@ -510,6 +507,7 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
@Override
public List<UserModel> getGroupMembers(RealmModel realm, GroupModel kcGroup, int firstResult, int maxResults) {
// TODO: with ranged search in AD we can improve the search using the specific range (not done for the moment)
LDAPObject ldapGroup = loadLDAPGroupByName(kcGroup.getName());
if (ldapGroup == null) {
return Collections.emptyList();
@ -539,7 +537,7 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
// Finally update LDAP membership in the parent group
if (highestGroupToSync.getParent() != null) {
LDAPObject ldapParentGroup = loadLDAPGroupByName(highestGroupToSync.getParent().getName());
LDAPUtils.addMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), getMembershipUserLdapAttribute(), ldapParentGroup, ldapGroup, true);
LDAPUtils.addMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), getMembershipUserLdapAttribute(), ldapParentGroup, ldapGroup);
}
} else {
// No care about group inheritance. Let's just sync current group
@ -551,7 +549,7 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
String membershipUserLdapAttrName = getMembershipUserLdapAttribute();
LDAPUtils.addMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), membershipUserLdapAttrName, ldapGroup, ldapUser, true);
LDAPUtils.addMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), membershipUserLdapAttrName, ldapGroup, ldapUser);
}
public void deleteGroupMappingInLDAP(LDAPObject ldapUser, LDAPObject ldapGroup) {

View file

@ -261,7 +261,7 @@ public class RoleLDAPStorageMapper extends AbstractLDAPStorageMapper implements
String membershipUserAttrName = getMembershipUserLdapAttribute();
LDAPUtils.addMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), membershipUserAttrName, ldapRole, ldapUser, true);
LDAPUtils.addMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), membershipUserAttrName, ldapRole, ldapUser);
}
public void deleteRoleMappingInLDAP(LDAPObject ldapUser, LDAPObject ldapRole) {

View file

@ -142,17 +142,17 @@ public class TestLDAPResource {
LDAPObject defaultGroup15 = LDAPTestUtils.createLDAPGroup(session, realm, ldapModel, "defaultGroup15", descriptionAttrName, "Default Group15 - description");
LDAPObject teamSubChild20262027 = LDAPTestUtils.createLDAPGroup(session, realm, ldapModel, "Team SubChild 2026/2027", descriptionAttrName, "A sub child group with slashes in the name");
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group11, false);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group12, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group11);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group12);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup1, defaultGroup11, false);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup1, defaultGroup12, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup1, teamChild20182019, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", teamChild20182019, teamSubChild20202021, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup13, teamSubChild20222023, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", teamSubChild20222023, defaultGroup14, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", teamRoot20242025, defaultGroup15, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup15, teamSubChild20262027, true);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup1, defaultGroup11);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup1, defaultGroup12);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup1, teamChild20182019);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", teamChild20182019, teamSubChild20202021);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup13, teamSubChild20222023);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", teamSubChild20222023, defaultGroup14);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", teamRoot20242025, defaultGroup15);
LDAPUtils.addMember(ldapFedProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", defaultGroup15, teamSubChild20262027);
// Sync LDAP groups to Keycloak DB
ComponentModel mapperModel = LDAPTestUtils.getSubcomponentByName(realm, ldapModel, "groupsMapper");

View file

@ -99,8 +99,8 @@ public class LDAPGroupMapperSyncTest extends AbstractLDAPTest {
LDAPObject group11 = LDAPTestUtils.createLDAPGroup(session, appRealm, ctx.getLdapModel(), "group11");
LDAPObject group12 = LDAPTestUtils.createLDAPGroup(session, appRealm, ctx.getLdapModel(), "group12", descriptionAttrName, "group12 - description");
LDAPUtils.addMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group11, false);
LDAPUtils.addMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group12, true);
LDAPUtils.addMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group11);
LDAPUtils.addMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, "not-used", group1, group12);
});
}
@ -135,7 +135,7 @@ public class LDAPGroupMapperSyncTest extends AbstractLDAPTest {
// Add recursive group mapping to LDAP. Check that sync with preserve group inheritance will fail
LDAPObject group1 = groupMapper.loadLDAPGroupByName("group1");
LDAPObject group12 = groupMapper.loadLDAPGroupByName("group12");
LDAPUtils.addMember(ldapProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group12, group1, true);
LDAPUtils.addMember(ldapProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group12, group1);
try {
new GroupLDAPStorageMapperFactory().create(session, mapperModel).syncDataFromFederationProviderToKeycloak(realm);
@ -298,7 +298,7 @@ public class LDAPGroupMapperSyncTest extends AbstractLDAPTest {
GroupMapperConfig groupMapperConfig = new GroupMapperConfig(mapperModel);
LDAPObject ldapGroup = groupMapper.loadLDAPGroupByName("group11");
LDAPUtils.addMember(ldapProvider, groupMapperConfig.getMembershipTypeLdapAttribute(), groupMapperConfig.getMembershipLdapAttribute(),
groupMapperConfig.getMembershipUserLdapAttribute(ldapProvider.getLdapIdentityStore().getConfig()), ldapGroup, johnLdap, true);
groupMapperConfig.getMembershipUserLdapAttribute(ldapProvider.getLdapIdentityStore().getConfig()), ldapGroup, johnLdap);
// Assert groups not yet imported to Keycloak DB
Assert.assertNull(KeycloakModelUtils.findGroupByPath(realm, "/group1"));

View file

@ -19,6 +19,8 @@ package org.keycloak.testsuite.federation.ldap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import javax.naming.directory.SearchControls;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.container.test.api.TargetsContainer;
@ -45,6 +47,7 @@ import org.keycloak.storage.ldap.mappers.membership.group.GroupLDAPStorageMapper
import org.keycloak.models.LDAPConstants;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
import org.keycloak.storage.ldap.mappers.membership.group.GroupMapperConfig;
import org.keycloak.testsuite.runonserver.RunOnServerDeployment;
import org.keycloak.testsuite.util.LDAPRule;
@ -476,14 +479,14 @@ public class LDAPGroupMapperTest extends AbstractLDAPTest {
// 2 - Add one existing user rob to LDAP group
LDAPObject jamesLdap = ldapProvider.loadLDAPUserByUsername(appRealm, "jameskeycloak");
LDAPUtils.addMember(ldapProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group2, jamesLdap, false);
LDAPUtils.addMember(ldapProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group2, jamesLdap);
// 3 - Add non-existing user to LDAP group
LDAPDn nonExistentDn = LDAPDn.fromString(ldapProvider.getLdapIdentityStore().getConfig().getUsersDn());
nonExistentDn.addFirst(jamesLdap.getRdnAttributeName(), "nonexistent");
LDAPObject nonExistentLdapUser = new LDAPObject();
nonExistentLdapUser.setDn(nonExistentDn);
LDAPUtils.addMember(ldapProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group2, nonExistentLdapUser, true);
LDAPUtils.addMember(ldapProvider, MembershipType.DN, LDAPConstants.MEMBER, "not-used", group2, nonExistentLdapUser);
// 4 - Check group members. Just existing user rob should be present
groupMapper.syncDataFromFederationProviderToKeycloak(appRealm);
@ -678,4 +681,111 @@ public class LDAPGroupMapperTest extends AbstractLDAPTest {
});
}
private static LDAPObject searchObjectInBase(LDAPStorageProvider ldapProvider, String dn, String... attrs) {
LDAPQuery q = new LDAPQuery(ldapProvider)
.setSearchDn(dn)
.setSearchScope(SearchControls.OBJECT_SCOPE);
if (attrs != null) {
for (String attr: attrs) {
q.addReturningLdapAttribute(attr);
}
}
return q.getFirstResult();
}
@Test
public void test08_ldapOnlyGroupMappingsRanged() {
testingClient.server().run(session -> {
int membersToTest = 61; // try to do 3 pages (30+30+1)
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
ComponentModel mapperModel = LDAPTestUtils.getSubcomponentByName(appRealm, ctx.getLdapModel(), "groupsMapper");
LDAPTestUtils.updateGroupMapperConfigOptions(mapperModel, GroupMapperConfig.MODE, LDAPGroupMapperMode.LDAP_ONLY.toString());
appRealm.updateComponent(mapperModel);
// create big grups that use ranged search
String descriptionAttrName = getGroupDescriptionLDAPAttrName(ctx.getLdapProvider());
LDAPObject bigGroup = LDAPTestUtils.createLDAPGroup(session, appRealm, ctx.getLdapModel(), "biggroup", descriptionAttrName, "biggroup - description");
// create the users to use range search and add them to the group
for (int i = 0; i < membersToTest; i++) {
String username = String.format("user%02d", i);
LDAPObject user = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, username, username, username, username + "@email.org", null, "1234");
LDAPUtils.addMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, "not-used", bigGroup, user);
}
// check if ranged intercetor is in place and working
GroupMapperConfig config = new GroupMapperConfig(mapperModel);
bigGroup = LDAPGroupMapperTest.searchObjectInBase(ctx.getLdapProvider(), bigGroup.getDn().toString(), config.getMembershipLdapAttribute());
Assert.assertNotNull(bigGroup.getAttributes().get(config.getMembershipLdapAttribute()));
Assert.assertFalse(bigGroup.isRangeComplete(config.getMembershipLdapAttribute()));
Assert.assertTrue(membersToTest > bigGroup.getAttributeAsSet(config.getMembershipLdapAttribute()).size());
Assert.assertEquals(bigGroup.getCurrentRange(config.getMembershipLdapAttribute()), bigGroup.getAttributeAsSet(config.getMembershipLdapAttribute()).size() - 1);
// now check the population of ranged attributes is OK
LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel());
GroupLDAPStorageMapper groupMapper = LDAPTestUtils.getGroupMapper(mapperModel, ldapProvider, appRealm);
groupMapper.syncDataFromFederationProviderToKeycloak(appRealm);
GroupModel kcBigGroup = KeycloakModelUtils.findGroupByPath(appRealm, "/biggroup");
// check all the users have the group assigned
for (int i = 0; i < membersToTest; i++) {
UserModel kcUser = session.users().getUserByUsername(String.format("user%02d", i), appRealm);
Assert.assertTrue("User contains biggroup " + i, kcUser.getGroups().contains(kcBigGroup));
}
// check the group contains all the users as member
List<UserModel> groupMembers = session.users().getGroupMembers(appRealm, kcBigGroup, 0, membersToTest);
Assert.assertEquals(membersToTest, groupMembers.size());
Set<String> usernames = groupMembers.stream().map(u -> u.getUsername()).collect(Collectors.toSet());
for (int i = 0; i < membersToTest; i++) {
Assert.assertTrue("Group contains user " + i, usernames.contains(String.format("user%02d", i)));
}
});
}
@Test
public void test09_emptyMemberOnDeletionWorks() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
ComponentModel mapperModel = LDAPTestUtils.getSubcomponentByName(appRealm, ctx.getLdapModel(), "groupsMapper");
// create a group with an existing user alone
String descriptionAttrName = getGroupDescriptionLDAPAttrName(ctx.getLdapProvider());
LDAPObject deleteGroup = LDAPTestUtils.createLDAPGroup(session, appRealm, ctx.getLdapModel(), "deletegroup", descriptionAttrName, "deletegroup - description");
LDAPObject maryLdap = ctx.getLdapProvider().loadLDAPUserByUsername(appRealm, "marykeycloak");
LDAPUtils.addMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, "not-used", deleteGroup, maryLdap);
LDAPObject empty = new LDAPObject();
empty.setDn(LDAPDn.fromString(LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE));
LDAPUtils.deleteMember(ctx.getLdapProvider(), MembershipType.DN, LDAPConstants.MEMBER, descriptionAttrName, deleteGroup, empty);
deleteGroup = LDAPGroupMapperTest.searchObjectInBase(ctx.getLdapProvider(), deleteGroup.getDn().toString(), LDAPConstants.MEMBER);
Assert.assertNotNull(deleteGroup);
Assert.assertEquals(1, deleteGroup.getAttributeAsSet(LDAPConstants.MEMBER).size());
Assert.assertEquals(maryLdap.getDn(), LDAPDn.fromString(deleteGroup.getAttributeAsString(LDAPConstants.MEMBER)));
// import into keycloak
LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel());
GroupLDAPStorageMapper groupMapper = LDAPTestUtils.getGroupMapper(mapperModel, ldapProvider, appRealm);
groupMapper.syncDataFromFederationProviderToKeycloak(appRealm);
// check everything is OK
GroupModel kcDeleteGroup = KeycloakModelUtils.findGroupByPath(appRealm, "/deletegroup");
UserModel mary = session.users().getUserByUsername("marykeycloak", appRealm);
List<UserModel> groupMembers = session.users().getGroupMembers(appRealm, kcDeleteGroup, 0, 5);
Assert.assertEquals(1, groupMembers.size());
Assert.assertEquals("marykeycloak", groupMembers.iterator().next().getUsername());
Set<GroupModel> maryGroups = mary.getGroups();
Assert.assertEquals(1, maryGroups.size());
Assert.assertEquals("deletegroup", maryGroups.iterator().next().getName());
// delete the group from mary to force schema violation and assingment of the empty value
mary.leaveGroup(kcDeleteGroup);
// check now the group has the empty member instead of mary
deleteGroup = LDAPGroupMapperTest.searchObjectInBase(ctx.getLdapProvider(), deleteGroup.getDn().toString(), LDAPConstants.MEMBER);
Assert.assertNotNull(deleteGroup);
Assert.assertEquals(1, deleteGroup.getAttributeAsSet(LDAPConstants.MEMBER).size());
Assert.assertEquals(LDAPDn.fromString(LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE), LDAPDn.fromString(deleteGroup.getAttributeAsString(LDAPConstants.MEMBER)));
});
}
}

View file

@ -46,6 +46,8 @@ import org.jboss.logging.Logger;
import java.io.File;
import java.io.IOException;
import java.util.List;
import org.apache.directory.server.core.api.interceptor.Interceptor;
import org.apache.directory.server.core.normalization.NormalizationInterceptor;
/**
* Factory for a fast (mostly in-memory-only) ApacheDS DirectoryService. Use only for tests!!
@ -55,6 +57,7 @@ import java.util.List;
class InMemoryDirectoryServiceFactory implements DirectoryServiceFactory {
private static final Logger log = Logger.getLogger(InMemoryDirectoryServiceFactory.class);
private static final int PAGE_SIZE = 30;
private final DirectoryService directoryService;
private final PartitionFactory partitionFactory;
@ -87,6 +90,7 @@ class InMemoryDirectoryServiceFactory implements DirectoryServiceFactory {
/**
* {@inheritDoc}
*/
@Override
public void init(String name) throws Exception {
if ((directoryService != null) && directoryService.isStarted()) {
return;
@ -144,12 +148,26 @@ class InMemoryDirectoryServiceFactory implements DirectoryServiceFactory {
systemPartition.setSchemaManager(directoryService.getSchemaManager());
partitionFactory.addIndex(systemPartition, SchemaConstants.OBJECT_CLASS_AT, 100);
directoryService.setSystemPartition(systemPartition);
// Find Normalization interceptor in chain and add our range emulated interceptor
List<Interceptor> interceptors = directoryService.getInterceptors();
int insertionPosition = -1;
for (int pos = 0; pos < interceptors.size(); ++pos) {
Interceptor interceptor = interceptors.get(pos);
if (interceptor instanceof NormalizationInterceptor) {
insertionPosition = pos;
}
}
interceptors.add(insertionPosition + 1, new RangedAttributeInterceptor("member", PAGE_SIZE));
directoryService.setInterceptors(interceptors);
directoryService.startup();
}
/**
* {@inheritDoc}
*/
@Override
public DirectoryService getDirectoryService() throws Exception {
return directoryService;
}
@ -157,6 +175,7 @@ class InMemoryDirectoryServiceFactory implements DirectoryServiceFactory {
/**
* {@inheritDoc}
*/
@Override
public PartitionFactory getPartitionFactory() throws Exception {
return partitionFactory;
}

View file

@ -0,0 +1,258 @@
/*
* Copyright 2019 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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 org.keycloak.util.ldap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import org.apache.directory.api.ldap.model.cursor.ClosureMonitor;
import org.apache.directory.api.ldap.model.cursor.CursorException;
import org.apache.directory.api.ldap.model.entry.Attribute;
import org.apache.directory.api.ldap.model.entry.Entry;
import org.apache.directory.api.ldap.model.entry.Value;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.schema.AttributeType;
import org.apache.directory.api.ldap.model.schema.AttributeTypeOptions;
import org.apache.directory.server.core.api.filtering.EntryFilter;
import org.apache.directory.server.core.api.filtering.EntryFilteringCursor;
import org.apache.directory.server.core.api.interceptor.BaseInterceptor;
import org.apache.directory.server.core.api.interceptor.context.SearchOperationContext;
/**
* <p>Ranged interceptor to emulate the behavior of AD. AD has a limit in
* the number of attributes that return (15000 by default in MaxValRange).
* See this MS link for AD limits:</p>
*
* https://support.microsoft.com/en-us/help/315071/how-to-view-and-set-ldap-policy-in-active-directory-by-using-ntdsutil
*
* <p>And this other link to know how range attribute search works:</p>
*
* https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ldap/searching-using-range-retrieval
*
* @author rmartinc
*/
public class RangedAttributeInterceptor extends BaseInterceptor {
private class RangedEntryFilteringCursor implements EntryFilteringCursor {
private final EntryFilteringCursor c;
private final String name;
private final Integer min;
private final Integer max;
public RangedEntryFilteringCursor(EntryFilteringCursor c, String name, Integer min, Integer max) {
this.c = c;
this.name = name;
this.min = min;
this.max = max;
AttributeType type = new AttributeType(name);
}
private Entry prepareEntry(Entry e) {
Attribute attr = e.get(name);
if (attr != null) {
int start = (min != null)? min : 0;
start = (start < attr.size())? start : attr.size() - 1;
int end = (max != null && max < attr.size() - 1)? max : attr.size() - 1;
if (start != 0 || end != attr.size() - 1) {
// some values should be stripped out
Iterator<Value<?>> it = attr.iterator();
Set<Value<?>> valuesToRemove = new HashSet<>(end - start + 1);
for (int i = 0; i < attr.size(); i++) {
Value<?> v = it.next();
if (i < start || i > end) {
valuesToRemove.add(v);
}
}
attr.setUpId(attr.getUpId() + ";range=" + start + "-" + ((end == attr.size() - 1)? "*" : end));
attr.remove(valuesToRemove.toArray(new Value<?>[0]));
} else if (min != null) {
// range explicitly requested although no value stripped
attr.setUpId(attr.getUpId() + ";range=0-*");
}
}
return e;
}
@Override
public boolean addEntryFilter(EntryFilter ef) {
return c.addEntryFilter(ef);
}
@Override
public List<EntryFilter> getEntryFilters() {
return c.getEntryFilters();
}
@Override
public SearchOperationContext getOperationContext() {
return c.getOperationContext();
}
@Override
public boolean available() {
return c.available();
}
@Override
public void before(Entry e) throws LdapException, CursorException {
c.before(e);
}
@Override
public void after(Entry e) throws LdapException, CursorException {
c.after(e);
}
@Override
public void beforeFirst() throws LdapException, CursorException {
c.beforeFirst();
}
@Override
public void afterLast() throws LdapException, CursorException {
c.afterLast();
}
@Override
public boolean first() throws LdapException, CursorException {
return c.first();
}
@Override
public boolean isFirst() {
return c.isFirst();
}
@Override
public boolean isBeforeFirst() {
return c.isBeforeFirst();
}
@Override
public boolean last() throws LdapException, CursorException {
return c.last();
}
@Override
public boolean isLast() {
return c.isLast();
}
@Override
public boolean isAfterLast() {
return c.isAfterLast();
}
@Override
public boolean isClosed() {
return c.isClosed();
}
@Override
public boolean previous() throws LdapException, CursorException {
return c.previous();
}
@Override
public boolean next() throws LdapException, CursorException {
return c.next();
}
@Override
public Entry get() throws CursorException {
return prepareEntry(c.get());
}
@Override
public void close() {
c.close();
}
@Override
public void close(Exception excptn) {
c.close(excptn);
}
@Override
public void setClosureMonitor(ClosureMonitor cm) {
c.setClosureMonitor(cm);
}
@Override
public String toString(String string) {
return c.toString(string);
}
@Override
public Iterator<Entry> iterator() {
return c.iterator();
}
}
private final String name;
private final int max;
public RangedAttributeInterceptor(String name, int max) {
this.name = name;
this.max = max - 1;
}
@Override
public EntryFilteringCursor search(SearchOperationContext sc) throws LdapException {
Set<AttributeTypeOptions> attrs = sc.getReturningAttributes();
Integer lmin = null, lmax = max;
if (attrs != null) {
for (AttributeTypeOptions attr : attrs) {
if (attr.getAttributeType().getName().equalsIgnoreCase(name)) {
if (attr.getOptions() != null) {
for (String option : attr.getOptions()) {
if (option.startsWith("range=")) {
String[] ranges = option.substring(6).split("-");
if (ranges.length == 2) {
try {
lmin = Integer.parseInt(ranges[0]);
if (lmin < 0) {
lmin = 0;
}
if ("*".equals(ranges[1])) {
lmax = lmin + max;
} else {
lmax = Integer.parseInt(ranges[1]);
if (lmax < lmin) {
lmax = lmin;
} else if (lmax > lmin + max) {
lmax = lmin + max;
}
}
} catch (NumberFormatException e) {
lmin = null;
lmax = max;
}
}
}
}
}
break;
}
}
}
return new RangedEntryFilteringCursor(super.next(sc), name, lmin, lmax);
}
}