Split UserQueryProvider into UserQueryMethods and UserCountMethods and make LdapStorageProvider implement only UserQueryMethods

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

Closed #20156
This commit is contained in:
vramik 2023-05-18 17:20:06 +02:00 committed by Hynek Mlnařík
parent d76c295c09
commit a175efcb72
11 changed files with 571 additions and 358 deletions

View file

@ -9,7 +9,9 @@ If you have examined the `UserStorageProvider` interface closely you might notic
|SPI|Description
|`org.keycloak.storage.user.UserLookupProvider`|This interface is required if you want to be able to log in with users from this external store. Most (all?) providers implement this interface.
|`org.keycloak.storage.user.UserQueryProvider`|Defines complex queries that are used to locate one or more users. You must implement this interface if you want to view and manage users from the administration console.
|`org.keycloak.storage.user.UserQueryMethodsProvider`|Defines complex queries that are used to locate one or more users. You must implement this interface if you want to view and manage users from the administration console.
|`org.keycloak.storage.user.UserCountMethodsProvider`|Implement this interface if your provider supports count queries.
|`org.keycloak.storage.user.UserQueryProvider`|This interface is combined capability of `UserQueryMethodsProvider` and `UserCountMethodsProvider`.
|`org.keycloak.storage.user.UserRegistrationProvider`|Implement this interface if your provider supports adding and removing users.
|`org.keycloak.storage.user.UserBulkUpdateProvider`|Implement this interface if your provider supports bulk update of a set of users.
|`org.keycloak.credential.CredentialInputValidator`|Implement this interface if your provider can validate one or more different credential types (for example, if your provider can validate a password).

View file

@ -3,7 +3,7 @@
One thing we have not done with our example is allow it to add and remove users or change passwords. Users defined in our example are
also not queryable or viewable in the Admin Console. To add these enhancements, our example provider must implement
the `UserQueryProvider` and `UserRegistrationProvider` interfaces.
the `UserQueryMethodsProvider` (or `UserQueryProvider`) and `UserRegistrationProvider` interfaces.
==== Implementing UserRegistrationProvider
@ -124,7 +124,7 @@ With these methods implemented, you'll now be able to change and disable the pas
==== Implementing UserQueryProvider
Without implementing `UserQueryProvider` the Admin Console would not be able to view and manage users that were loaded
`UserQueryProvider` is combination of `UserQueryMethodsProvider` and `UserCountMethodsProvider`. Without implementing `UserQueryMethodsProvider` the Admin Console would not be able to view and manage users that were loaded
by our example provider. Let's look at implementing this interface.
.PropertyFileUserStorageProvider

View file

@ -191,11 +191,19 @@ The implementation has now been unified for all themes. In general, the message
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
= `UserQueryProvider` changes
`UserQueryProvider` interface was split into two. One is `UserQueryMethodsProvider` providing capabilities for querying users. Second one is `UserCountMethodsProvider` which provides capability for counting number of users in particular storage.
Keycloak now has the ability to differentiate between user storage providers that can efficiently execute count queries and those that cannot. The `UserQueryProvider` interface still exists and extends both new interfaces. Therefore, there is no need for any modifications in the existing implementations of `UserQueryProvider` since it retains the same methods.
= `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.
Since this release `LDAPStorageProvider` implements only `UserQueryMethodsProvider`, not `UserQueryProvider`.
= Deprecation of Keycloak OpenID Connect Adapters
Starting with this release, we no longer will invest our time on the following Keycloak OpenID Connect Adapters:

View file

@ -18,7 +18,6 @@
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;
@ -84,7 +83,7 @@ import org.keycloak.storage.ldap.mappers.LDAPStorageMapperManager;
import org.keycloak.storage.ldap.mappers.PasswordUpdateCallback;
import org.keycloak.storage.user.ImportedUserValidation;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryProvider;
import org.keycloak.storage.user.UserQueryMethodsProvider;
import org.keycloak.storage.user.UserRegistrationProvider;
import static org.keycloak.utils.StreamsUtil.paginatedStream;
@ -100,7 +99,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
CredentialAuthentication,
UserLookupProvider,
UserRegistrationProvider,
UserQueryProvider,
UserQueryMethodsProvider,
ImportedUserValidation {
private static final Logger logger = Logger.getLogger(LDAPStorageProvider.class);
private static final int DEFAULT_MAX_RESULTS = Integer.MAX_VALUE >> 1;
@ -344,36 +343,6 @@ public class LDAPStorageProvider implements UserStorageProvider,
return getUserByUsername(realm, storageId.getExternalId());
}
@Override
public int getUsersCount(RealmModel realm) {
return getUsersCount(realm, Collections.emptyMap());
}
@Override
public int getUsersCount(RealmModel realm, String search) {
return (int) searchLDAP(realm, search, null, null).filter(filterLocalUsers(realm)).count();
}
@Override
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) {
return searchForUserStream(realm, Map.of(UserModel.SEARCH, search), firstResult, maxResults);

View file

@ -27,6 +27,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
@ -64,7 +65,9 @@ import org.keycloak.storage.federated.UserFederatedStorageProvider;
import org.keycloak.storage.managers.UserStorageSyncManager;
import org.keycloak.storage.user.ImportedUserValidation;
import org.keycloak.storage.user.UserBulkUpdateProvider;
import org.keycloak.storage.user.UserCountMethodsProvider;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryMethodsProvider;
import org.keycloak.storage.user.UserQueryProvider;
import org.keycloak.storage.user.UserRegistrationProvider;
@ -202,7 +205,7 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
protected Stream<UserModel> query(PaginatedQuery pagedQuery, CountQuery countQuery, RealmModel realm, Integer firstResult, Integer maxResults) {
if (maxResults != null && maxResults == 0) return Stream.empty();
Stream<Object> providersStream = Stream.concat(Stream.of((Object) localStorage()), getEnabledStorageProviders(realm, UserQueryProvider.class));
Stream<Object> providersStream = Stream.concat(Stream.of((Object) localStorage()), getEnabledStorageProviders(realm, UserQueryMethodsProvider.class));
UserFederatedStorageProvider federatedStorageProvider = getFederatedStorage();
if (federatedStorageProvider != null) {
@ -210,10 +213,12 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
}
final AtomicInteger currentFirst;
final AtomicBoolean needsAdditionalFirstResultFiltering = new AtomicBoolean(false);
if (firstResult == null || firstResult <= 0) { // We don't want to skip any users so we don't need to do firstResult filtering
currentFirst = new AtomicInteger(0);
} else {
// This is an optimization using count query to skip querying users if we can use count method to determine how many users can be provided by each provider
AtomicBoolean droppingProviders = new AtomicBoolean(true);
currentFirst = new AtomicInteger(firstResult);
@ -221,7 +226,16 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
.filter(provider -> { // This is basically dropWhile
if (!droppingProviders.get()) return true; // We have already gathered enough users to pass firstResult number in previous providers, we can take all following providers
if (!(provider instanceof UserCountMethodsProvider)) {
logger.tracef("We encountered a provider (%s) that does not implement count queries therefore we can't say how many users it can provide.", provider.getClass().getSimpleName());
// for this reason we need to start querying this provider and all following providers
droppingProviders.set(false);
needsAdditionalFirstResultFiltering.set(true);
return true; // don't filter out this provider because we are unable to say how many users it can provide
}
long expectedNumberOfUsersForProvider = countQuery.query(provider, 0, currentFirst.get() + 1); // check how many users we can obtain from this provider
logger.tracef("This provider (%s) is able to return %d users.", provider.getClass().getSimpleName(), expectedNumberOfUsersForProvider);
if (expectedNumberOfUsersForProvider == currentFirst.get()) { // This provider provides exactly the amount of users we need for passing firstResult, we can set currentFirst to 0 and drop this provider
currentFirst.set(0);
@ -234,10 +248,31 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
return true; // don't filter out this provider because we are going to return some users from it
}
// This provider cannot provide enough users to pass firstResult so we are going to filter it out and change firstResult for next provider
logger.tracef("This provider (%s) cannot provide enough users to pass firstResult so we are going to filter it out and change "
+ "firstResult for next provider: %d - %d = %d", provider.getClass().getSimpleName(),
currentFirst.get(), expectedNumberOfUsersForProvider, currentFirst.get() - expectedNumberOfUsersForProvider);
currentFirst.set((int) (currentFirst.get() - expectedNumberOfUsersForProvider));
return false;
});
})
// collecting stream of providers to ensure the filtering (above) is evaluated before we move forward to actual querying
.collect(Collectors.toList()).stream();
}
if (needsAdditionalFirstResultFiltering.get() && currentFirst.get() > 0) {
logger.tracef("In the providerStream there is a provider that does not support count queries and we need to skip some users.");
// we need to make sure, we skip firstResult users from this or the following providers
if (maxResults == null || maxResults < 0) {
return paginatedStream(providersStream
.flatMap(provider -> pagedQuery.query(provider, null, null)), currentFirst.get(), null);
} else {
final AtomicInteger currentMax = new AtomicInteger(currentFirst.get() + maxResults);
return paginatedStream(providersStream
.flatMap(provider -> pagedQuery.query(provider, null, currentMax.get()))
.peek(userModel -> {
currentMax.updateAndGet(i -> i > 0 ? i - 1 : i);
}), currentFirst.get(), maxResults);
}
}
// Actual user querying
@ -355,8 +390,8 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
@Override
public Stream<UserModel> getGroupMembersStream(final RealmModel realm, final GroupModel group, Integer firstResult, Integer maxResults) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
return ((UserQueryProvider)provider).getGroupMembersStream(realm, group, firstResultInQuery, maxResultsInQuery);
if (provider instanceof UserQueryMethodsProvider) {
return ((UserQueryMethodsProvider)provider).getGroupMembersStream(realm, group, firstResultInQuery, maxResultsInQuery);
} else if (provider instanceof UserFederatedStorageProvider) {
return ((UserFederatedStorageProvider)provider).getMembershipStream(realm, group, firstResultInQuery, maxResultsInQuery).
@ -371,8 +406,8 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
@Override
public Stream<UserModel> getRoleMembersStream(final RealmModel realm, final RoleModel role, Integer firstResult, Integer maxResults) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
return ((UserQueryProvider)provider).getRoleMembersStream(realm, role, firstResultInQuery, maxResultsInQuery);
if (provider instanceof UserQueryMethodsProvider) {
return ((UserQueryMethodsProvider)provider).getRoleMembersStream(realm, role, firstResultInQuery, maxResultsInQuery);
}
return Stream.empty();
}, realm, firstResult, maxResults);
@ -382,7 +417,7 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
@Override
public int getUsersCount(RealmModel realm, boolean includeServiceAccount) {
int localStorageUsersCount = localStorage().getUsersCount(realm, includeServiceAccount);
int storageProvidersUsersCount = mapEnabledStorageProvidersWithTimeout(realm, UserQueryProvider.class,
int storageProvidersUsersCount = mapEnabledStorageProvidersWithTimeout(realm, UserCountMethodsProvider.class,
userQueryProvider -> userQueryProvider.getUsersCount(realm))
.reduce(0, Integer::sum);
@ -422,13 +457,13 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
return ((UserQueryProvider)provider).searchForUserStream(realm, search, firstResultInQuery, maxResultsInQuery);
if (provider instanceof UserQueryMethodsProvider) {
return ((UserQueryMethodsProvider)provider).searchForUserStream(realm, search, firstResultInQuery, maxResultsInQuery);
}
return Stream.empty();
}, (provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
return ((UserQueryProvider)provider).getUsersCount(realm, search);
if (provider instanceof UserCountMethodsProvider) {
return ((UserCountMethodsProvider)provider).getUsersCount(realm, search);
}
return 0;
}, realm, firstResult, maxResults);
@ -438,21 +473,21 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
if (provider instanceof UserQueryMethodsProvider) {
if (attributes.containsKey(UserModel.SEARCH)) {
return ((UserQueryProvider)provider).searchForUserStream(realm, attributes.get(UserModel.SEARCH), firstResultInQuery, maxResultsInQuery);
return ((UserQueryMethodsProvider)provider).searchForUserStream(realm, attributes.get(UserModel.SEARCH), firstResultInQuery, maxResultsInQuery);
} else {
return ((UserQueryProvider)provider).searchForUserStream(realm, attributes, firstResultInQuery, maxResultsInQuery);
return ((UserQueryMethodsProvider)provider).searchForUserStream(realm, attributes, firstResultInQuery, maxResultsInQuery);
}
}
return Stream.empty();
},
(provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
if (provider instanceof UserCountMethodsProvider) {
if (attributes.containsKey(UserModel.SEARCH)) {
return ((UserQueryProvider)provider).getUsersCount(realm, attributes.get(UserModel.SEARCH));
return ((UserCountMethodsProvider)provider).getUsersCount(realm, attributes.get(UserModel.SEARCH));
} else {
return ((UserQueryProvider)provider).getUsersCount(realm, attributes);
return ((UserCountMethodsProvider)provider).getUsersCount(realm, attributes);
}
}
return 0;
@ -464,8 +499,8 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryProvider) {
return paginatedStream(((UserQueryProvider)provider).searchForUserByUserAttributeStream(realm, attrName, attrValue), firstResultInQuery, maxResultsInQuery);
if (provider instanceof UserQueryMethodsProvider) {
return paginatedStream(((UserQueryMethodsProvider)provider).searchForUserByUserAttributeStream(realm, attrName, attrValue), firstResultInQuery, maxResultsInQuery);
} else if (provider instanceof UserFederatedStorageProvider) {
return paginatedStream(((UserFederatedStorageProvider)provider).getUsersByUserAttributeStream(realm, attrName, attrValue)
.map(id -> getUserById(realm, id))

View file

@ -28,7 +28,9 @@ import org.keycloak.provider.Provider;
* are extended by implementing one or more of the following capability interfaces:
* <ul>
* <li>{@link org.keycloak.storage.user.UserLookupProvider UserLookupProvider} - Provide basic lookup methods. After implementing it is possible to login using users from the storage.</li>
* <li>{@link org.keycloak.storage.user.UserQueryProvider UserQueryProvider} - Provide complex lookup methods. After implementing it is possible to manage users from admin console.</li>
* <li>{@link org.keycloak.storage.user.UserQueryMethodsProvider UserQueryMethodsProvider} - Provide complex lookup methods. After implementing it is possible to manage users from admin console.</li>
* <li>{@link org.keycloak.storage.user.UserCountMethodsProvider UserCountMethodsProvider} - Provide complex count methods. After implementing it is possible to leverage optimizations during querying for users.</li>
* <li>{@link org.keycloak.storage.user.UserQueryProvider UserQueryProvider} - This interface is combined capability of {@code UserQueryMethodsProvider} and {@code UserCountMethodsProvider}.</li>
* <li>{@link org.keycloak.storage.user.UserRegistrationProvider UserRegistrationProvider} - Provide methods for adding users. After implementing it is possible to store registered users in the storage.</li>
* <li>{@link org.keycloak.storage.user.UserBulkUpdateProvider UserBulkUpdateProvider} - After implementing it is possible to perform bulk operations on all users from storage (for example, addition of a role to all users).</li>
* <li>{@link org.keycloak.storage.user.ImportedUserValidation ImportedUserValidation} - Provider method for validating users within Keycloak local storage that are imported from the storage.</li>

View file

@ -0,0 +1,149 @@
/*
* 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.storage.user;
import org.keycloak.models.GroupModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
/**
* This is an optional capability interface that is intended to be implemented by
* <code>UserStorageProvider</code> that supports count queries.
*
* By implementing this interface, count queries could be leveraged when querying users with some offset ({@code firstResult}).
*
* <p/>
* Note that all methods in this interface should limit search only to data available within the storage that is
* represented by this provider. They should not lookup other storage providers for additional information.
*/
public interface UserCountMethodsProvider {
/**
* Returns the number of users, without consider any service account.
*
* @param realm the realm
* @return the number of users
*/
default int getUsersCount(RealmModel realm) {
return getUsersCount(realm, false);
}
/**
* Returns the number of users that are in at least one of the groups
* given.
*
* @param realm the realm
* @param groupIds set of groups IDs, the returned user needs to belong to at least one of them
* @return the number of users that are in at least one of the groups
*/
default int getUsersCount(RealmModel realm, Set<String> groupIds) {
if (groupIds == null || groupIds.isEmpty() || !(this instanceof UserQueryMethodsProvider)) {
return 0;
}
return countUsersInGroups(((UserQueryMethodsProvider)this).searchForUserStream(realm, Collections.emptyMap()), groupIds);
}
/**
* Returns the number of users that would be returned by a call to {@link #searchForUserStream(RealmModel, String) searchForUserStream}
*
* @param realm the realm
* @param search case insensitive list of strings separated by whitespaces.
* @return number of users that match the search
*/
default int getUsersCount(RealmModel realm, String search) {
if (!(this instanceof UserQueryMethodsProvider)) {
return 0;
}
return (int) ((UserQueryMethodsProvider)this).searchForUserStream(realm, search).count();
}
/**
* Returns the number of users that would be returned by a call to {@link #searchForUserStream(RealmModel, String) searchForUserStream}
* and are members of at least one of the groups given by the {@code groupIds} set.
*
* @param realm the realm
* @param search case insensitive list of strings separated by whitespaces.
* @param groupIds set of groups IDs, the returned user needs to belong to at least one of them
* @return number of users that match the search and given groups
*/
default int getUsersCount(RealmModel realm, String search, Set<String> groupIds) {
if (groupIds == null || groupIds.isEmpty() || !(this instanceof UserQueryMethodsProvider)) {
return 0;
}
return countUsersInGroups(((UserQueryMethodsProvider)this).searchForUserStream(realm, search), groupIds);
}
/**
* Returns the number of users that match the given filter parameters.
*
* @param realm the realm
* @param params filter parameters
* @return number of users that match the given filters
*/
default int getUsersCount(RealmModel realm, Map<String, String> params) {
if (!(this instanceof UserQueryMethodsProvider)) {
return 0;
}
return (int) ((UserQueryMethodsProvider)this).searchForUserStream(realm, params).count();
}
/**
* Returns the number of users that match the given filter parameters and is in
* at least one of the given groups.
*
* @param params filter parameters
* @param realm the realm
* @param groupIds set if groups to check for
* @return number of users that match the given filters and groups
*/
default int getUsersCount(RealmModel realm, Map<String, String> params, Set<String> groupIds) {
if (groupIds == null || groupIds.isEmpty() || !(this instanceof UserQueryMethodsProvider)) {
return 0;
}
return countUsersInGroups(((UserQueryMethodsProvider)this).searchForUserStream(realm, params), groupIds);
}
/**
* Returns the number of users from the given list of users that are in at
* least one of the groups given in the groups set.
*
* @param users list of users to check
* @param groupIds id of groups that should be checked for
* @return number of users that are in at least one of the groups
*/
static int countUsersInGroups(Stream<UserModel> users, Set<String> groupIds) {
return (int) users.filter(u -> u.getGroupsStream().map(GroupModel::getId).anyMatch(groupIds::contains)).count();
}
/**
* Returns the number of users.
*
* @param realm the realm
* @param includeServiceAccount if true, the number of users will also include service accounts. Otherwise, only the number of users.
* @return the number of users
*/
default int getUsersCount(RealmModel realm, boolean includeServiceAccount) {
throw new RuntimeException("Not implemented");
}
}

View file

@ -0,0 +1,210 @@
/*
* 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.storage.user;
import org.keycloak.models.GroupModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Stream;
/**
* This is an optional capability interface that is intended to be implemented by any
* <code>UserStorageProvider</code> that supports complex user querying. You must
* implement this interface if you want to view and manage users from the administration console.
* <p/>
* Note that all methods in this interface should limit search only to data available within the storage that is
* represented by this provider. They should not lookup other storage providers for additional information.
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public interface UserQueryMethodsProvider {
/**
* Searches all users in the realm.
*
* @param realm a reference to the realm.
* @return a non-null {@link Stream} of users.
* @deprecated Use {@link #searchForUserStream(RealmModel, Map)} with an empty params map instead.
*/
@Deprecated
default Stream<UserModel> getUsersStream(RealmModel realm) {
return searchForUserStream(realm, Collections.emptyMap());
}
/**
* Searches all users in the realm, starting from the {@code firstResult} and containing at most {@code maxResults}.
*
* @param realm a reference to the realm.
* @param firstResult first result to return. Ignored if negative or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users.
* @deprecated Use {@link #searchForUserStream(RealmModel, Map, Integer, Integer)} with an empty params map instead.
*/
@Deprecated
default Stream<UserModel> getUsersStream(RealmModel realm, Integer firstResult, Integer maxResults) {
return searchForUserStream(realm, Collections.emptyMap(), firstResult, maxResults);
}
/**
* Searches for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace.
* <p/>
* If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE).
* <p/>
* This method is used by the admin console search box
*
* @param realm a reference to the realm.
* @param search case insensitive list of string separated by whitespaces.
* @return a non-null {@link Stream} of users that match the search string.
*/
default Stream<UserModel> searchForUserStream(RealmModel realm, String search) {
return searchForUserStream(realm, search, null, null);
}
/**
* Searches for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace.
* <p/>
* If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE).
* <p/>
* This method is used by the admin console search box
*
* @param realm a reference to the realm.
* @param search case insensitive list of string separated by whitespaces.
* @param firstResult first result to return. Ignored if negative, zero, or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that match the search criteria.
*/
Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults);
/**
* Searches for user by parameter.
* If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE).
* <p/>
* Valid parameters are:
* <ul>
* <li>{@link UserModel#FIRST_NAME} - first name (case insensitive string)</li>
* <li>{@link UserModel#LAST_NAME} - last name (case insensitive string)</li>
* <li>{@link UserModel#EMAIL} - email (case insensitive string)</li>
* <li>{@link UserModel#USERNAME} - username (case insensitive string)</li>
* <li>{@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)</li>
* <li>{@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)</li>
* <li>{@link UserModel#IDP_ALIAS} - search only for users that have a federated identity
* from idp with the given alias configured (case sensitive string)</li>
* <li>{@link UserModel#IDP_USER_ID} - search for users with federated identity with
* the given userId (case sensitive string)</li>
* </ul>
* <p>
* This method is used by the REST API when querying users.
*
* @param realm a reference to the realm.
* @param params a map containing the search parameters.
* @return a non-null {@link Stream} of users that match the search parameters.
*/
default Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params) {
return searchForUserStream(realm, params, null, null);
}
/**
* Searches for user by parameter. If possible, implementations should treat the parameter values as partial match patterns
* (i.e. in RDMBS terms use LIKE).
* <p/>
* Valid parameters are:
* <ul>
* <li>{@link UserModel#FIRST_NAME} - first name (case insensitive string)</li>
* <li>{@link UserModel#LAST_NAME} - last name (case insensitive string)</li>
* <li>{@link UserModel#EMAIL} - email (case insensitive string)</li>
* <li>{@link UserModel#USERNAME} - username (case insensitive string)</li>
* <li>{@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)</li>
* <li>{@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)</li>
* <li>{@link UserModel#IDP_ALIAS} - search only for users that have a federated identity
* from idp with the given alias configured (case sensitive string)</li>
* <li>{@link UserModel#IDP_USER_ID} - search for users with federated identity with
* the given userId (case sensitive string)</li>
* </ul>
* <p>
* Any other parameters will be treated as custom user attributes.
* <p>
* This method is used by the REST API when querying users.
*
* @param realm a reference to the realm.
* @param params a map containing the search parameters.
* @param firstResult first result to return. Ignored if negative, zero, or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that match the search criteria.
*/
Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults);
/**
* Obtains users that belong to a specific group.
*
* @param realm a reference to the realm.
* @param group a reference to the group.
* @return a non-null {@link Stream} of users that belong to the group.
*/
default Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group) {
return getGroupMembersStream(realm, group, null, null);
}
/**
* Obtains users that belong to a specific group.
*
* @param realm a reference to the realm.
* @param group a reference to the group.
* @param firstResult first result to return. Ignored if negative, zero, or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that belong to the group.
*/
Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults);
/**
* Obtains users that have the specified role.
*
* @param realm a reference to the realm.
* @param role a reference to the role.
* @return a non-null {@link Stream} of users that have the specified role.
*/
default Stream<UserModel> getRoleMembersStream(RealmModel realm, RoleModel role) {
return getRoleMembersStream(realm, role, null, null);
}
/**
* Searches for users that have the specified role.
*
* @param realm a reference to the realm.
* @param role a reference to the role.
* @param firstResult first result to return. Ignored if negative or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that have the specified role.
*/
default Stream<UserModel> getRoleMembersStream(RealmModel realm, RoleModel role, Integer firstResult, Integer maxResults) {
return Stream.empty();
}
/**
* Searches for users that have a specific attribute with a specific value.
*
* @param realm a reference to the realm.
* @param attrName the attribute name.
* @param attrValue the attribute value.
* @return a non-null {@link Stream} of users that match the search criteria.
*/
Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue);
}

View file

@ -16,304 +16,13 @@
*/
package org.keycloak.storage.user;
import org.keycloak.models.GroupModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
*
* This is an optional capability interface that is intended to be implemented by any
* <code>UserStorageProvider</code> that supports complex user querying. You must
* implement this interface if you want to view and manage users from the administration console.
* <p/>
* Note that all methods in this interface should limit search only to data available within the storage that is
* represented by this provider. They should not lookup other storage providers for additional information.
* <code>UserStorageProvider</code> that supports complex user querying.
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
* It's a combination of {@link UserQueryMethodsProvider} and {@link UserCountMethodsProvider}
*/
public interface UserQueryProvider {
/**
* Returns the number of users, without consider any service account.
*
* @param realm the realm
* @return the number of users
*/
default int getUsersCount(RealmModel realm) {
return getUsersCount(realm, false);
}
/**
* Returns the number of users that are in at least one of the groups
* given.
*
* @param realm the realm
* @param groupIds set of groups IDs, the returned user needs to belong to at least one of them
* @return the number of users that are in at least one of the groups
*/
default int getUsersCount(RealmModel realm, Set<String> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return 0;
}
return countUsersInGroups(searchForUserStream(realm, Collections.emptyMap()), groupIds);
}
/**
* Returns the number of users that would be returned by a call to {@link #searchForUserStream(RealmModel, String) searchForUserStream}
*
* @param realm the realm
* @param search case insensitive list of strings separated by whitespaces.
* @return number of users that match the search
*/
default int getUsersCount(RealmModel realm, String search) {
return (int) searchForUserStream(realm, search).count();
}
/**
* Returns the number of users that would be returned by a call to {@link #searchForUserStream(RealmModel, String) searchForUserStream}
* and are members of at least one of the groups given by the {@code groupIds} set.
*
* @param realm the realm
* @param search case insensitive list of strings separated by whitespaces.
* @param groupIds set of groups IDs, the returned user needs to belong to at least one of them
* @return number of users that match the search and given groups
*/
default int getUsersCount(RealmModel realm, String search, Set<String> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return 0;
}
return countUsersInGroups(searchForUserStream(realm, search), groupIds);
}
/**
* Returns the number of users that match the given filter parameters.
*
* @param realm the realm
* @param params filter parameters
* @return number of users that match the given filters
*/
default int getUsersCount(RealmModel realm, Map<String, String> params) {
return (int) searchForUserStream(realm, params).count();
}
/**
* Returns the number of users that match the given filter parameters and is in
* at least one of the given groups.
*
* @param params filter parameters
* @param realm the realm
* @param groupIds set if groups to check for
* @return number of users that match the given filters and groups
*/
default int getUsersCount(RealmModel realm, Map<String, String> params, Set<String> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return 0;
}
return countUsersInGroups(searchForUserStream(realm, params), groupIds);
}
/**
* Returns the number of users from the given list of users that are in at
* least one of the groups given in the groups set.
*
* @param users list of users to check
* @param groupIds id of groups that should be checked for
* @return number of users that are in at least one of the groups
*/
static int countUsersInGroups(Stream<UserModel> users, Set<String> groupIds) {
return (int) users.filter(u -> u.getGroupsStream().map(GroupModel::getId).anyMatch(groupIds::contains)).count();
}
/**
* Returns the number of users.
*
* @param realm the realm
* @param includeServiceAccount if true, the number of users will also include service accounts. Otherwise, only the number of users.
* @return the number of users
*/
default int getUsersCount(RealmModel realm, boolean includeServiceAccount) {
throw new RuntimeException("Not implemented");
}
/**
* Searches all users in the realm.
*
* @param realm a reference to the realm.
* @return a non-null {@link Stream} of users.
* @deprecated Use {@link #searchForUserStream(RealmModel, Map)} with an empty params map instead.
*/
@Deprecated
default Stream<UserModel> getUsersStream(RealmModel realm) {
return searchForUserStream(realm, Collections.emptyMap());
}
/**
* Searches all users in the realm, starting from the {@code firstResult} and containing at most {@code maxResults}.
*
* @param realm a reference to the realm.
* @param firstResult first result to return. Ignored if negative or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users.
* @deprecated Use {@link #searchForUserStream(RealmModel, Map, Integer, Integer)} with an empty params map instead.
*/
@Deprecated
default Stream<UserModel> getUsersStream(RealmModel realm, Integer firstResult, Integer maxResults) {
return searchForUserStream(realm, Collections.emptyMap(), firstResult, maxResults);
}
/**
* Searches for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace.
* <p/>
* If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE).
* <p/>
* This method is used by the admin console search box
*
* @param realm a reference to the realm.
* @param search case insensitive list of string separated by whitespaces.
* @return a non-null {@link Stream} of users that match the search string.
*/
default Stream<UserModel> searchForUserStream(RealmModel realm, String search) {
return searchForUserStream(realm, search, null, null);
}
/**
* Searches for users whose username, email, first name or last name contain any of the strings in {@code search} separated by whitespace.
* <p/>
* If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE).
* <p/>
* This method is used by the admin console search box
*
* @param realm a reference to the realm.
* @param search case insensitive list of string separated by whitespaces.
* @param firstResult first result to return. Ignored if negative, zero, or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that match the search criteria.
*/
Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults);
/**
* Searches for user by parameter.
* If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE).
* <p/>
* Valid parameters are:
* <ul>
* <li>{@link UserModel#FIRST_NAME} - first name (case insensitive string)</li>
* <li>{@link UserModel#LAST_NAME} - last name (case insensitive string)</li>
* <li>{@link UserModel#EMAIL} - email (case insensitive string)</li>
* <li>{@link UserModel#USERNAME} - username (case insensitive string)</li>
* <li>{@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)</li>
* <li>{@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)</li>
* <li>{@link UserModel#IDP_ALIAS} - search only for users that have a federated identity
* from idp with the given alias configured (case sensitive string)</li>
* <li>{@link UserModel#IDP_USER_ID} - search for users with federated identity with
* the given userId (case sensitive string)</li>
* </ul>
*
* This method is used by the REST API when querying users.
*
* @param realm a reference to the realm.
* @param params a map containing the search parameters.
* @return a non-null {@link Stream} of users that match the search parameters.
*/
default Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params) {
return searchForUserStream(realm, params, null, null);
}
/**
* Searches for user by parameter. If possible, implementations should treat the parameter values as partial match patterns
* (i.e. in RDMBS terms use LIKE).
* <p/>
* Valid parameters are:
* <ul>
* <li>{@link UserModel#FIRST_NAME} - first name (case insensitive string)</li>
* <li>{@link UserModel#LAST_NAME} - last name (case insensitive string)</li>
* <li>{@link UserModel#EMAIL} - email (case insensitive string)</li>
* <li>{@link UserModel#USERNAME} - username (case insensitive string)</li>
* <li>{@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)</li>
* <li>{@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)</li>
* <li>{@link UserModel#IDP_ALIAS} - search only for users that have a federated identity
* from idp with the given alias configured (case sensitive string)</li>
* <li>{@link UserModel#IDP_USER_ID} - search for users with federated identity with
* the given userId (case sensitive string)</li>
* </ul>
*
* Any other parameters will be treated as custom user attributes.
*
* This method is used by the REST API when querying users.
*
* @param realm a reference to the realm.
* @param params a map containing the search parameters.
* @param firstResult first result to return. Ignored if negative, zero, or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that match the search criteria.
*/
Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults);
/**
* Obtains users that belong to a specific group.
*
* @param realm a reference to the realm.
* @param group a reference to the group.
* @return a non-null {@link Stream} of users that belong to the group.
*/
default Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group) {
return getGroupMembersStream(realm, group, null, null);
}
/**
* Obtains users that belong to a specific group.
*
* @param realm a reference to the realm.
* @param group a reference to the group.
* @param firstResult first result to return. Ignored if negative, zero, or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that belong to the group.
*/
Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults);
/**
* Obtains users that have the specified role.
*
* @param realm a reference to the realm.
* @param role a reference to the role.
* @return a non-null {@link Stream} of users that have the specified role.
*/
default Stream<UserModel> getRoleMembersStream(RealmModel realm, RoleModel role) {
return getRoleMembersStream(realm, role, null, null);
}
/**
* Searches for users that have the specified role.
*
* @param realm a reference to the realm.
* @param role a reference to the role.
* @param firstResult first result to return. Ignored if negative or {@code null}.
* @param maxResults maximum number of results to return. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of users that have the specified role.
*/
default Stream<UserModel> getRoleMembersStream(RealmModel realm, RoleModel role, Integer firstResult, Integer maxResults) {
return Stream.empty();
}
/**
* Searches for users that have a specific attribute with a specific value.
*
* @param realm a reference to the realm.
* @param attrName the attribute name.
* @param attrValue the attribute value.
* @return a non-null {@link Stream} of users that match the search criteria.
*/
Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue);
public interface UserQueryProvider extends UserQueryMethodsProvider, UserCountMethodsProvider {
/**
* @deprecated This interface is no longer necessary, collection-based methods were removed from the parent interface

View file

@ -225,10 +225,14 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
String search = Optional.ofNullable(attributes.get(UserModel.USERNAME))
.orElseGet(()-> attributes.get(UserModel.SEARCH));
if (search == null) return Stream.empty();
Predicate<String> p = Boolean.valueOf(attributes.getOrDefault(UserModel.EXACT, Boolean.FALSE.toString()))
Predicate<String> p;
if (search == null) {
p = x -> true;
} else {
p = Boolean.parseBoolean(attributes.getOrDefault(UserModel.EXACT, Boolean.FALSE.toString()))
? username -> username.equals(search)
: username -> username.contains(search);
}
return searchForUser(realm, search, firstResult, maxResults, p);
}

View file

@ -0,0 +1,125 @@
/*
* 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.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.io.FileUtils;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.RealmModel;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.testsuite.federation.UserPropertyFileStorageFactory;
import org.keycloak.testsuite.util.LDAPRule;
import org.keycloak.testsuite.util.LDAPTestUtils;
/**
* It tests correct behavior when using {@code firstResult} during querying users
* with a provider not-implementing {@code UserCountMethodsProvider} - LDAPStorageProvider.
*/
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class LDAPCountQueryTest extends AbstractLDAPTest {
private static final File CONFIG_DIR = new File(System.getProperty("auth.server.config.dir", ""));
@ClassRule
public static LDAPRule ldapRule = new LDAPRule();
@Override
protected LDAPRule getLDAPRule() {
return ldapRule;
}
@Override
protected void afterImportTestRealm() {
copyPropertiesFiles();
final String ldapComponentId = ldapModelId;
final String propertyFile = CONFIG_DIR.getAbsolutePath() + File.separator + "user-password.properties";
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
// set high priority to ensure the provider which doesn't implement UserCountMethodsProvider is queried first
ComponentModel ldapComponent = appRealm.getComponent(ldapComponentId);
ldapComponent.getConfig().putSingle("priority", Integer.toString(0));
appRealm.updateComponent(ldapComponent);
// Delete all local users and add some new for testing
session.users().searchForUserStream(appRealm, Map.of()).collect(Collectors.toList()).forEach(u -> session.users().removeUser(appRealm, u));
LDAPTestUtils.addLocalUser(session, appRealm, "user1", "user1@email", "password");
LDAPTestUtils.addLocalUser(session, appRealm, "user2", "user2@email", "password");
// Delete all LDAP users and add some new for testing
LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm);
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");
// add user-prop provider with lower priority
ComponentModel userPropProvider = new ComponentModel();
userPropProvider.setName("user-props");
userPropProvider.setProviderId(UserPropertyFileStorageFactory.PROVIDER_ID);
userPropProvider.setProviderType(UserStorageProvider.class.getName());
userPropProvider.setConfig(new MultivaluedHashMap<>());
userPropProvider.getConfig().putSingle("priority", Integer.toString(1));
userPropProvider.getConfig().putSingle("propertyFile", propertyFile);
userPropProvider.getConfig().putSingle("federatedStorage", "false");
appRealm.addComponentModel(userPropProvider);
});
}
private void copyPropertiesFiles() throws RuntimeException {
try {
// copy files used by the following user-props user provider
File stResDir = new File(getClass().getResource("/storage-test").toURI());
if (stResDir.exists() && stResDir.isDirectory() && CONFIG_DIR.exists() && CONFIG_DIR.isDirectory()) {
for (File f : stResDir.listFiles()) {
log.infof("Copying %s to %s", f.getName(), CONFIG_DIR.getAbsolutePath());
FileUtils.copyFileToDirectory(f, CONFIG_DIR);
}
} else {
throw new RuntimeException("Property `auth.server.config.dir` must be set to run the test.");
}
} catch (IOException | RuntimeException | URISyntaxException e) {
throw new RuntimeException(e);
}
}
@Test
public void testFirstResultWithMultipleProviders() {
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(6, null), hasSize(4));
}
@Test
public void testFirstResultWithMultipleProvidersMaxResultSet() {
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(6, 20), hasSize(4));
}
}