Make LDAP searchForUsersStream consistent with other storages

Co-authored-by: mhajas <mhajas@redhat.com>

Closes #17294
This commit is contained in:
vramik 2023-03-10 12:19:11 +01:00 committed by Michal Hajas
parent b6a4b0f803
commit fd6a6ec3ad
13 changed files with 431 additions and 128 deletions

View file

@ -195,3 +195,8 @@ Up to this version, the resolving of fallback messages was inconsistent across t
The implementation has now been unified for all themes. In general, the message for the most specific matching language tag has the highest priority. If there are both a realm localization message and a Theme 18n message, the realm localization message has the higher priority. Summarized, the priority of the messages is as follows (RL = realm localization, T = Theme i18n files): `RL <variant> > T <variant> > RL <region> > T <region> > RL <language> > T <language> > RL en > T en`.
Probably this can be better explained with an example: When the variant `de-CH-1996` is requested and there is a realm localization message for the variant, this message will be used. If such a realm localization message does not exist, the Theme i18n files are searched for a corresponding message for that variant. If such a message does not exist, a realm localization message for the region (`de-CH`) will be searched. If such a realm localization message does not exist, the Theme i18n files are searched for a message for that region. If still no message is found, a realm localization message for the language (`de`) will be searched. If there is no matching realm localization message, the Theme i18n files are be searched for a message for that language. As last fallback, the English (`en`) translation is used: First, an English realm localization will be searched - if not found, the Theme 18n files are searched for an English message.
= LDAPStorageProvider search changes
Starting with this release Keycloak uses a pagination mechanism when querying federated LDAP database.
Searching for users should be consistent with search in local database.

View file

@ -29,6 +29,12 @@
<name>Keycloak LDAP UserStoreProvider</name>
<description />
<properties>
<maven.compiler.release>11</maven.compiler.release>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>

View file

@ -18,8 +18,10 @@
package org.keycloak.storage.ldap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -28,6 +30,7 @@ import java.util.function.Predicate;
import java.util.stream.Stream;
import javax.naming.AuthenticationException;
import javax.naming.NamingException;
import org.jboss.logging.Logger;
import org.keycloak.common.constants.KerberosConstants;
@ -343,53 +346,59 @@ public class LDAPStorageProvider implements UserStorageProvider,
@Override
public int getUsersCount(RealmModel realm) {
return 0;
return getUsersCount(realm, Collections.emptyMap());
}
@Override
public Stream<UserModel> getUsersStream(RealmModel realm) {
return Stream.empty();
public int getUsersCount(RealmModel realm, String search) {
return (int) searchLDAP(realm, search, null, null).filter(filterLocalUsers(realm)).count();
}
@Override
public Stream<UserModel> getUsersStream(RealmModel realm, Integer firstResult, Integer maxResults) {
return Stream.empty();
public int getUsersCount(RealmModel realm, Map<String, String> params) {
return (int) searchLDAPByAttributes(realm, params, null, null).filter(filterLocalUsers(realm)).count();
}
@Override
public int getUsersCount(RealmModel realm, Set<String> groupIds) {
throw new UnsupportedOperationException("Not implemented");
}
@Override
public int getUsersCount(RealmModel realm, String search, Set<String> groupIds) {
throw new UnsupportedOperationException("Not implemented");
}
@Override
public int getUsersCount(RealmModel realm, Map<String, String> params, Set<String> groupIds) {
throw new UnsupportedOperationException("Not implemented");
}
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
Map<String, String> attributes = new HashMap<String, String>();
attributes.put(UserModel.SEARCH,search);
return searchForUserStream(realm, attributes, firstResult, maxResults);
return searchForUserStream(realm, Map.of(UserModel.SEARCH, search), firstResult, maxResults);
}
/**
* It supports
* <ul>
* <li>{@link UserModel#FIRST_NAME}</li>
* <li>{@link UserModel#LAST_NAME}</li>
* <li>{@link UserModel#EMAIL}</li>
* <li>{@link UserModel#USERNAME}</li>
* </ul>
*
* Other fields are not supported. The search for LDAP REST endpoints is done in the context of fields which are stored in LDAP (above).
*/
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
String search = params.get(UserModel.SEARCH);
if(search!=null) {
int spaceIndex = search.lastIndexOf(' ');
if (spaceIndex > -1) {
String firstName = search.substring(0, spaceIndex).trim();
String lastName = search.substring(spaceIndex).trim();
params.put(UserModel.FIRST_NAME, firstName);
params.put(UserModel.LAST_NAME, lastName);
} else if (search.indexOf('@') > -1) {
params.put(UserModel.USERNAME, search.trim().toLowerCase());
params.put(UserModel.EMAIL, search.trim().toLowerCase());
} else {
params.put(UserModel.LAST_NAME, search.trim());
params.put(UserModel.USERNAME, search.trim().toLowerCase());
}
}
Stream<LDAPObject> result = search != null ?
searchLDAP(realm, search, firstResult, maxResults) :
searchLDAPByAttributes(realm, params, firstResult, maxResults);
Stream<LDAPObject> stream = searchLDAP(realm, params).stream()
.filter(ldapObject -> {
String ldapUsername = LDAPUtils.getUsername(ldapObject, this.ldapIdentityStore.getConfig());
return (UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, ldapUsername) == null);
});
return paginatedStream(stream, firstResult, maxResults).map(ldapObject -> importUserFromLDAP(session, realm, ldapObject));
return paginatedStream(result.filter(filterLocalUsers(realm)), firstResult, maxResults)
.map(ldapObject -> importUserFromLDAP(session, realm, ldapObject));
}
@Override
@ -434,71 +443,72 @@ public class LDAPStorageProvider implements UserStorageProvider,
return result;
}
protected List<LDAPObject> searchLDAP(RealmModel realm, Map<String, String> attributes) {
/**
* Searches LDAP using logical conjunction of params. It supports
* <ul>
* <li>{@link UserModel#FIRST_NAME}</li>
* <li>{@link UserModel#LAST_NAME}</li>
* <li>{@link UserModel#EMAIL}</li>
* <li>{@link UserModel#USERNAME}</li>
* </ul>
*
* For zero or any other param it returns all users.
*/
private Stream<LDAPObject> searchLDAPByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
// return a stable ordered result to the caller
List<LDAPObject> results = new ArrayList<>();
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
// a set to ensure fast uniqueness checks based on equals/hashCode of LDAPObject
Set<LDAPObject> unique = new HashSet<>();
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
if (attributes.containsKey(UserModel.USERNAME)) {
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
// Mapper should replace "username" in parameter name with correct LDAP mapped attribute
Condition usernameCondition = conditionsBuilder.equal(UserModel.USERNAME, attributes.get(UserModel.USERNAME), EscapeStrategy.NON_ASCII_CHARS_ONLY);
ldapQuery.addWhereCondition(usernameCondition);
List<LDAPObject> ldapObjects = ldapQuery.getResultList();
results.addAll(ldapObjects);
unique.addAll(ldapObjects);
// Mapper should replace parameter with correct LDAP mapped attributes
if (attributes.containsKey(UserModel.USERNAME)) {
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.USERNAME, attributes.get(UserModel.USERNAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
}
if (attributes.containsKey(UserModel.EMAIL)) {
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
// Mapper should replace "email" in parameter name with correct LDAP mapped attribute
Condition emailCondition = conditionsBuilder.equal(UserModel.EMAIL, attributes.get(UserModel.EMAIL), EscapeStrategy.NON_ASCII_CHARS_ONLY);
ldapQuery.addWhereCondition(emailCondition);
List<LDAPObject> ldapObjects = ldapQuery.getResultList();
ldapObjects.forEach(ldapObject -> {
// ensure that no entity is listed twice and still preserve the order of returned entities
if (!unique.contains(ldapObject)) {
results.add(ldapObject);
unique.add(ldapObject);
}
});
if (attributes.containsKey(UserModel.EMAIL)) {
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.EMAIL, attributes.get(UserModel.EMAIL), EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
}
if (attributes.containsKey(UserModel.FIRST_NAME) || attributes.containsKey(UserModel.LAST_NAME)) {
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
// Mapper should replace parameter with correct LDAP mapped attributes
if (attributes.containsKey(UserModel.FIRST_NAME)) {
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.FIRST_NAME, attributes.get(UserModel.FIRST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
if (attributes.containsKey(UserModel.LAST_NAME)) {
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.LAST_NAME, attributes.get(UserModel.LAST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
List<LDAPObject> ldapObjects = ldapQuery.getResultList();
ldapObjects.forEach(ldapObject -> {
// ensure that no entity is listed twice and still preserve the order of returned entities
if (!unique.contains(ldapObject)) {
results.add(ldapObject);
unique.add(ldapObject);
}
});
if (attributes.containsKey(UserModel.FIRST_NAME)) {
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.FIRST_NAME, attributes.get(UserModel.FIRST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
if (attributes.containsKey(UserModel.LAST_NAME)) {
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.LAST_NAME, attributes.get(UserModel.LAST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
// for all other searchable fields: Ignoring is the fallback option, since it may overestimate the results but does not ignore matches.
// for empty params: all users are returned (pagination applies)
return paginatedSearchLDAP(ldapQuery, firstResult, maxResults);
}
}
return results;
/**
* Searches LDAP using logical disjunction of params. It supports
* <ul>
* <li>{@link UserModel#FIRST_NAME}</li>
* <li>{@link UserModel#LAST_NAME}</li>
* <li>{@link UserModel#EMAIL}</li>
* <li>{@link UserModel#USERNAME}</li>
* </ul>
*
* It uses multiple LDAP calls and results are combined together with respect to firstResult and maxResults
*
* This method serves for {@code search} param of {@link org.keycloak.services.resources.admin.UsersResource#getUsers}
*/
private Stream<LDAPObject> searchLDAP(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
List<Condition> conditions = new LinkedList<>();
for (String s : search.split("\\s+")) {
conditions.add(conditionsBuilder.equal(UserModel.USERNAME, s.trim().toLowerCase(), EscapeStrategy.NON_ASCII_CHARS_ONLY));
conditions.add(conditionsBuilder.equal(UserModel.EMAIL, s.trim().toLowerCase(), EscapeStrategy.NON_ASCII_CHARS_ONLY));
conditions.add(conditionsBuilder.equal(UserModel.FIRST_NAME, s, EscapeStrategy.NON_ASCII_CHARS_ONLY));
conditions.add(conditionsBuilder.equal(UserModel.LAST_NAME, s, EscapeStrategy.NON_ASCII_CHARS_ONLY));
}
ldapQuery.addWhereCondition(conditionsBuilder.orCondition(conditions.toArray(Condition[]::new)));
return paginatedSearchLDAP(ldapQuery, firstResult, maxResults);
}
}
/**
@ -542,7 +552,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
String ldapUsername = LDAPUtils.getUsername(ldapUser, ldapIdentityStore.getConfig());
LDAPUtils.checkUuid(ldapUser, ldapIdentityStore.getConfig());
UserModel imported = null;
UserModel imported;
if (model.isImportEnabled()) {
// Search if there is already an existing user, which means the username might have changed in LDAP without Keycloak knowing about it
UserModel existingLocalUser = UserStoragePrivateUtil.userLocalStorage(session)
@ -717,7 +727,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
}
public Set<String> getSupportedCredentialTypes() {
return new HashSet<String>(this.supportedCredentialTypes);
return new HashSet<>(this.supportedCredentialTypes);
}
@ -752,7 +762,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
spnegoAuthenticator.authenticate();
Map<String, String> state = new HashMap<String, String>();
Map<String, String> state = new HashMap<>();
if (spnegoAuthenticator.isAuthenticated()) {
// TODO: This assumes that LDAP "uid" is equal to kerberos principal name. Like uid "hnelson" and kerberos principal "hnelson@KEYCLOAK.ORG".
@ -858,4 +868,63 @@ public class LDAPStorageProvider implements UserStorageProvider,
}
}
private Predicate<LDAPObject> filterLocalUsers(RealmModel realm) {
return ldapObject -> UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, LDAPUtils.getUsername(ldapObject, LDAPStorageProvider.this.ldapIdentityStore.getConfig())) == null;
}
/**
* This method leverages existing pagination support in {@link LDAPQuery#getResultList()}. It sets the limit for the query
* based on {@code firstResult}, {@code maxResults} and {@link LDAPConfig#getBatchSizeForSync()}.
*
* <p/>
* Internally it uses {@link Stream#iterate(java.lang.Object, java.util.function.Predicate, java.util.function.UnaryOperator)}
* to ensure there will be obtained required number of users considering a fact that some of the returned ldap users could be
* filtered out (as they might be already imported in local storage). The returned {@code Stream<LDAPObject>} will be filled
* "on demand".
*/
private Stream<LDAPObject> paginatedSearchLDAP(LDAPQuery ldapQuery, Integer firstResult, Integer maxResults) {
LDAPConfig ldapConfig = ldapQuery.getLdapProvider().getLdapIdentityStore().getConfig();
if (ldapConfig.isPagination()) {
final int limit;
if (maxResults != null && maxResults >= 0) {
if (firstResult != null && firstResult > 0) {
limit = Integer.min(ldapConfig.getBatchSizeForSync(), Integer.sum(firstResult, maxResults));
} else {
limit = Integer.min(ldapConfig.getBatchSizeForSync(), maxResults);
}
} else {
if (firstResult != null && firstResult > 0) {
limit = Integer.min(ldapConfig.getBatchSizeForSync(), firstResult);
} else {
limit = ldapConfig.getBatchSizeForSync();
}
}
return Stream.iterate(ldapQuery,
query -> {
//the very 1st page - Pagination context might not yet be present
if (query.getPaginationContext() == null) try {
query.initPagination();
//returning true for first iteration as the LDAP was not queried yet
return true;
} catch (NamingException e) {
throw new ModelException("Querying of LDAP failed " + query, e);
}
return query.getPaginationContext().hasNextPage();
},
query -> query
).flatMap(query -> {
query.setLimit(limit);
List<LDAPObject> ldapObjects = query.getResultList();
if (ldapObjects.isEmpty()) {
return Stream.empty();
}
return ldapObjects.stream();
});
}
return ldapQuery.getResultList().stream();
}
}

View file

@ -51,20 +51,19 @@ public class LDAPQuery implements AutoCloseable {
private final LDAPStorageProvider ldapFedProvider;
private int offset;
private int limit;
private PaginationContext paginationContext;
private LDAPContextManager ldapContextManager;
private String searchDn;
private final Set<Condition> conditions = new LinkedHashSet<Condition>();
private final Set<Sort> ordering = new LinkedHashSet<Sort>();
private final Set<Condition> conditions = new LinkedHashSet<>();
private final Set<Sort> ordering = new LinkedHashSet<>();
private final Set<String> returningLdapAttributes = new LinkedHashSet<String>();
private final Set<String> returningLdapAttributes = new LinkedHashSet<>();
// Contains just those returningLdapAttributes, which are read-only. They will be marked as read-only in returned LDAPObject instances as well
// NOTE: names of attributes are lower-cased to avoid case sensitivity issues (LDAP searching is usually case-insensitive, so we want to be as well)
private final Set<String> returningReadOnlyLdapAttributes = new LinkedHashSet<String>();
private final Set<String> objectClasses = new LinkedHashSet<String>();
private final Set<String> returningReadOnlyLdapAttributes = new LinkedHashSet<>();
private final Set<String> objectClasses = new LinkedHashSet<>();
private final List<ComponentModel> mappers = new ArrayList<>();
@ -146,10 +145,6 @@ public class LDAPQuery implements AutoCloseable {
return limit;
}
public int getOffset() {
return offset;
}
public PaginationContext getPaginationContext() {
return paginationContext;
}
@ -166,7 +161,7 @@ public class LDAPQuery implements AutoCloseable {
fedMapper.beforeLDAPQuery(this);
}
List<LDAPObject> result = new ArrayList<LDAPObject>();
List<LDAPObject> result = new ArrayList<>();
try {
for (LDAPObject ldapObject : ldapFedProvider.getLdapIdentityStore().fetchQueryResults(this)) {
@ -195,11 +190,6 @@ public class LDAPQuery implements AutoCloseable {
return ldapFedProvider.getLdapIdentityStore().countQueryResults(this);
}
public LDAPQuery setOffset(int offset) {
this.offset = offset;
return this;
}
public LDAPQuery setLimit(int limit) {
this.limit = limit;
return this;

View file

@ -71,9 +71,9 @@ public final class LDAPContextManager implements AutoCloseable {
if (!LDAPConstants.AUTH_TYPE_NONE.equals(ldapConfig.getAuthType())) {
vaultCharSecret = getVaultSecret();
if (vaultCharSecret != null && !ldapConfig.isStartTls()) {
if (vaultCharSecret != null && !ldapConfig.isStartTls() && ldapConfig.getBindCredential() != null) {
connProp.put(SECURITY_CREDENTIALS, vaultCharSecret.getAsArray()
.orElse(ldapConfig.getBindCredential() != null? ldapConfig.getBindCredential().toCharArray() : null));
.orElse(ldapConfig.getBindCredential().toCharArray()));
}
}
@ -140,7 +140,7 @@ public final class LDAPContextManager implements AutoCloseable {
if(!ldapConfig.isStartTls()) {
String authType = ldapConfig.getAuthType();
env.put(Context.SECURITY_AUTHENTICATION, authType);
if (authType != null) env.put(Context.SECURITY_AUTHENTICATION, authType);
String bindDN = ldapConfig.getBindDN();
@ -151,8 +151,8 @@ public final class LDAPContextManager implements AutoCloseable {
}
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
env.put(Context.SECURITY_PRINCIPAL, bindDN);
env.put(Context.SECURITY_CREDENTIALS, bindCredential);
if (bindDN != null) env.put(Context.SECURITY_PRINCIPAL, bindDN);
if (bindCredential != null) env.put(Context.SECURITY_CREDENTIALS, bindCredential);
}
}

View file

@ -295,15 +295,12 @@ public class LDAPIdentityStore implements IdentityStore {
@Override
public int countQueryResults(LDAPQuery identityQuery) {
int limit = identityQuery.getLimit();
int offset = identityQuery.getOffset();
identityQuery.setLimit(0);
identityQuery.setOffset(0);
int resultCount = identityQuery.getResultList().size();
identityQuery.setLimit(limit);
identityQuery.setOffset(offset);
return resultCount;
}

View file

@ -103,13 +103,13 @@ public class LDAPOperationManager {
*/
public void modifyAttributes(String dn, NamingEnumeration<Attribute> attributes) {
try {
List<ModificationItem> modItems = new ArrayList<ModificationItem>();
List<ModificationItem> modItems = new ArrayList<>();
while (attributes.hasMore()) {
ModificationItem modItem = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attributes.next());
modItems.add(modItem);
}
modifyAttributes(dn, modItems.toArray(new ModificationItem[] {}), null);
modifyAttributes(dn, modItems.toArray(ModificationItem[]::new), null);
} catch (NamingException ne) {
throw new ModelException("Could not modify attributes on entry from DN [" + dn + "]", ne);
}
@ -246,7 +246,7 @@ public class LDAPOperationManager {
public List<SearchResult> search(final String baseDN, final String filter, Collection<String> returningAttributes, int searchScope) throws NamingException {
final List<SearchResult> result = new ArrayList<SearchResult>();
final List<SearchResult> result = new ArrayList<>();
final SearchControls cons = getSearchControls(returningAttributes, searchScope);
try {
@ -285,7 +285,7 @@ public class LDAPOperationManager {
}
public List<SearchResult> searchPaginated(final String baseDN, final String filter, final LDAPQuery identityQuery) throws NamingException {
final List<SearchResult> result = new ArrayList<SearchResult>();
final List<SearchResult> result = new ArrayList<>();
final SearchControls cons = getSearchControls(identityQuery.getReturningLdapAttributes(), identityQuery.getSearchScope());
// Very 1st page. Pagination context is not yet present

View file

@ -62,7 +62,6 @@ import org.keycloak.storage.ldap.mappers.LDAPStorageMapper;
import org.keycloak.testsuite.ProfileAssume;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.client.KeycloakTestingClient;
import org.keycloak.testsuite.util.RealmRepUtil;
import java.io.IOException;
import java.util.ArrayList;
@ -78,6 +77,7 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.keycloak.util.JsonSerialization;
@ -107,7 +107,6 @@ public class ExportImportUtil {
RealmResource realmRsc = adminClient.realm(realm.getRealm());
/* See KEYCLOAK-3104*/
UserRepresentation user = findByUsername(realmRsc, "loginclient");
Assert.assertNotNull(user);
@ -478,12 +477,10 @@ public class ExportImportUtil {
return false;
}
// Workaround for KEYCLOAK-3104. For this realm, search() only works if username is null.
private static UserRepresentation findByUsername(RealmResource realmRsc, String username) {
for (UserRepresentation user : realmRsc.users().search(null, 0, -1)) {
if (user.getUsername().equalsIgnoreCase(username)) return user;
}
return null;
List<UserRepresentation> usersByUsername = realmRsc.users().search(username);
MatcherAssert.assertThat(usersByUsername, Matchers.hasSize(1));
return usersByUsername.get(0);
}
private static Set<RoleRepresentation> allScopeMappings(ClientResource client) {

View file

@ -43,8 +43,6 @@ import org.junit.BeforeClass;
*/
public abstract class AbstractLDAPTest extends AbstractTestRealmKeycloakTest {
static final String TEST_REALM_NAME = "test";
protected static String ldapModelId;
@Rule

View file

@ -0,0 +1,99 @@
/*
* Copyright 2023 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.testsuite.federation.ldap;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.TreeSet;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.util.LDAPRule;
import org.keycloak.testsuite.util.LDAPTestUtils;
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class LDAPSearchForUsersPaginationTest extends AbstractLDAPTest {
@ClassRule
public static LDAPRule ldapRule = new LDAPRule();
@Override
protected LDAPRule getLDAPRule() {
return ldapRule;
}
@Override
protected void afterImportTestRealm() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
// Delete all local users and add some new for testing
session.users().searchForUserStream(appRealm, new HashMap<>()).collect(Collectors.toList()).forEach(u -> session.users().removeUser(appRealm, u));
// Delete all LDAP users and add some new for testing
LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm);
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john", "Some", "Some", "john14@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john00", "john", "Doe", "john0@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john01", "john", "Doe", "john1@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john02", "john", "Doe", "john2@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john03", "john", "Doe", "john3@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john04", "john", "Doe", "john4@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john05", "Some", "john", "john5@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john06", "Some", "john", "john6@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john07", "Some", "john", "john7@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john08", "Some", "john", "john8@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john09", "Some", "john", "john9@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john10", "Some", "Some", "john10@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john11", "Some", "Some", "john11@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john12", "Some", "Some", "john12@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john13", "Some", "Some", "john13@email.org", null, "1234");
});
}
@Test
public void testPagination() {
//this call should import some users into local database
//collecting to TreeSet for ordering as users are orderd by username when querying from local database
@SuppressWarnings("unchecked")
LinkedList<String> importedUsers = new LinkedList(adminClient.realm(TEST_REALM_NAME).users().search("*", 0, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toCollection(TreeSet::new)));
//this call should ommit first 3 already imported users from local db
//it should return 2 local(imported) users and 8 users from ldap
List<String> search = adminClient.realm(TEST_REALM_NAME).users().search("*", 3, 10).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
assertThat(search, hasSize(10));
assertThat(search, not(contains(importedUsers.get(0))));
assertThat(search, not(contains(importedUsers.get(1))));
assertThat(search, not(contains(importedUsers.get(2))));
assertThat(search, hasItems(importedUsers.get(3), importedUsers.get(4)));
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright 2023 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.testsuite.federation.ldap.noimport;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.federation.ldap.AbstractLDAPTest;
import org.keycloak.testsuite.federation.ldap.LDAPTestContext;
import org.keycloak.testsuite.util.LDAPRule;
import org.keycloak.testsuite.util.LDAPTestUtils;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class LDAPSearchForUsersPaginationNoImportTest extends AbstractLDAPTest {
@ClassRule
public static LDAPRule ldapRule = new LDAPRule();
@Override
protected LDAPRule getLDAPRule() {
return ldapRule;
}
@Override
public boolean isImportEnabled() {
// always load users from ldap directly
return false;
}
@Override
protected void afterImportTestRealm() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
// Delete all local users to not interfere with federated ones
session.users().searchForUserStream(appRealm, new HashMap<>()).collect(Collectors.toList()).forEach(u -> session.users().removeUser(appRealm, u));
// Delete all LDAP users and add some new for testing
LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm);
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john", "Some", "Some", "john14@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john00", "john", "Doe", "john0@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john01", "john", "Doe", "john1@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john02", "john", "Doe", "john2@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john03", "john", "Doe", "john3@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john04", "john", "Doe", "john4@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john05", "Some", "john", "john5@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john06", "Some", "john", "john6@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john07", "Some", "john", "john7@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john08", "Some", "john", "john8@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john09", "Some", "john", "john9@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john10", "Some", "Some", "john10@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john11", "Some", "Some", "john11@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john12", "Some", "Some", "john12@email.org", null, "1234");
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john13", "Some", "Some", "john13@email.org", null, "1234");
});
}
@Test
public void testPagination() {
//tests LDAPStorageProvider.searchLDAP(...
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John Some Doe", 0, 15), hasSize(15));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John Some Doe", 7, 10), hasSize(8));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", null, null), hasSize(15));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", null, null), hasSize(15));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 10, 8), hasSize(5));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 0, 10), hasSize(10));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 7, 10), hasSize(8));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 15, 100), hasSize(0));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 14, 2), hasSize(1));
//tests LDAPStorageProvider.searchLDAP(...
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", null, null), hasSize(11));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 10, 8), hasSize(1));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 0, 10), hasSize(10));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 0, 5), hasSize(5));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 2, 10), hasSize(9));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 0, 8), hasSize(8));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("Some", 0, 20), hasSize(10));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("Some", 10, 20), hasSize(0));
//tests LDAPStorageProvider.searchLDAPByAttributes(...
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(), hasSize(15));
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(10, 8), hasSize(5));
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(0, 10), hasSize(10));
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(7, 10), hasSize(8));
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(15, 100), hasSize(0));
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(14, 2), hasSize(1));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search(null, "John", null, null, 0, 15), hasSize(5));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search(null, "Some", "John", null, 0, 15), hasSize(5));
assertThat(adminClient.realm(TEST_REALM_NAME).users().search(null, "Some", "John", null, 2, 15), hasSize(3));
}
@Test
public void testReturnedOrder() {
List<String> firstFive = adminClient.realm(TEST_REALM_NAME).users().search("*", 0, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
List<String> secondFive = adminClient.realm(TEST_REALM_NAME).users().search("*", 5, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
List<String> thirdFive = adminClient.realm(TEST_REALM_NAME).users().search("*", 10, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
firstFive.forEach(username -> assertThat(secondFive, not(hasItem(username))));
firstFive.forEach(username -> assertThat(thirdFive, not(hasItem(username))));
secondFive.forEach(username -> assertThat(firstFive, not(hasItem(username))));
secondFive.forEach(username -> assertThat(thirdFive, not(hasItem(username))));
thirdFive.forEach(username -> assertThat(firstFive, not(hasItem(username))));
thirdFive.forEach(username -> assertThat(secondFive, not(hasItem(username))));
}
}

View file

@ -3517,6 +3517,7 @@
"displayName" : "ldap-provider",
"providerName" : "ldap",
"config" : {
"enabled" : "false",
"serverPrincipal" : "principal",
"debug" : "true",
"pagination" : "true",

View file

@ -56,6 +56,7 @@
"providerName": "ldap",
"priority": 1,
"config": {
"enabled": false,
"connectionUrl": "ldap://foo",
"editMode": "WRITABLE"
}
@ -65,6 +66,7 @@
"providerName": "ldap",
"priority": 2,
"config": {
"enabled": false,
"connectionUrl": "ldap://bar",
"editMode": "WRITABLE"
}