diff --git a/docs/documentation/server_development/topics/user-storage/provider-capability-interfaces.adoc b/docs/documentation/server_development/topics/user-storage/provider-capability-interfaces.adoc index 59cf144212..e49451f7fe 100644 --- a/docs/documentation/server_development/topics/user-storage/provider-capability-interfaces.adoc +++ b/docs/documentation/server_development/topics/user-storage/provider-capability-interfaces.adoc @@ -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). diff --git a/docs/documentation/server_development/topics/user-storage/registration-query.adoc b/docs/documentation/server_development/topics/user-storage/registration-query.adoc index 313dc2d6c9..7f46136260 100644 --- a/docs/documentation/server_development/topics/user-storage/registration-query.adoc +++ b/docs/documentation/server_development/topics/user-storage/registration-query.adoc @@ -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 diff --git a/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc b/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc index 740f4d2086..a7cf5d6398 100644 --- a/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc +++ b/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc @@ -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 -Starting with this release Keycloak uses a pagination mechanism when querying federated LDAP database. +`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: diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java index e7c3db36b5..c4d78eebcc 100755 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java @@ -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 params) { - return (int) searchLDAPByAttributes(realm, params, null, null).filter(filterLocalUsers(realm)).count(); - } - - @Override - public int getUsersCount(RealmModel realm, Set groupIds) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public int getUsersCount(RealmModel realm, String search, Set groupIds) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public int getUsersCount(RealmModel realm, Map params, Set groupIds) { - throw new UnsupportedOperationException("Not implemented"); - } - @Override public Stream searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) { return searchForUserStream(realm, Map.of(UserModel.SEARCH, search), firstResult, maxResults); diff --git a/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java index 6fda667620..112f060694 100755 --- a/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -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 query(PaginatedQuery pagedQuery, CountQuery countQuery, RealmModel realm, Integer firstResult, Integer maxResults) { if (maxResults != null && maxResults == 0) return Stream.empty(); - Stream providersStream = Stream.concat(Stream.of((Object) localStorage()), getEnabledStorageProviders(realm, UserQueryProvider.class)); + Stream 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 { // 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 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 getGroupMembersStream(final RealmModel realm, final GroupModel group, Integer firstResult, Integer maxResults) { Stream 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 getRoleMembersStream(final RealmModel realm, final RoleModel role, Integer firstResult, Integer maxResults) { Stream 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 userQueryProvider.getUsersCount(realm)) .reduce(0, Integer::sum); @@ -422,13 +457,13 @@ public class UserStorageManager extends AbstractStorageManager searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) { Stream 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 searchForUserStream(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { Stream 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 searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) { Stream 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)) diff --git a/model/legacy/src/main/java/org/keycloak/storage/UserStorageProvider.java b/model/legacy/src/main/java/org/keycloak/storage/UserStorageProvider.java index 31bb5ce814..2dd36d41d2 100644 --- a/model/legacy/src/main/java/org/keycloak/storage/UserStorageProvider.java +++ b/model/legacy/src/main/java/org/keycloak/storage/UserStorageProvider.java @@ -28,7 +28,9 @@ import org.keycloak.provider.Provider; * are extended by implementing one or more of the following capability interfaces: *
    *
  • {@link org.keycloak.storage.user.UserLookupProvider UserLookupProvider} - Provide basic lookup methods. After implementing it is possible to login using users from the storage.
  • - *
  • {@link org.keycloak.storage.user.UserQueryProvider UserQueryProvider} - Provide complex lookup methods. After implementing it is possible to manage users from admin console.
  • + *
  • {@link org.keycloak.storage.user.UserQueryMethodsProvider UserQueryMethodsProvider} - Provide complex lookup methods. After implementing it is possible to manage users from admin console.
  • + *
  • {@link org.keycloak.storage.user.UserCountMethodsProvider UserCountMethodsProvider} - Provide complex count methods. After implementing it is possible to leverage optimizations during querying for users.
  • + *
  • {@link org.keycloak.storage.user.UserQueryProvider UserQueryProvider} - This interface is combined capability of {@code UserQueryMethodsProvider} and {@code UserCountMethodsProvider}.
  • *
  • {@link org.keycloak.storage.user.UserRegistrationProvider UserRegistrationProvider} - Provide methods for adding users. After implementing it is possible to store registered users in the storage.
  • *
  • {@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).
  • *
  • {@link org.keycloak.storage.user.ImportedUserValidation ImportedUserValidation} - Provider method for validating users within Keycloak local storage that are imported from the storage.
  • diff --git a/server-spi/src/main/java/org/keycloak/storage/user/UserCountMethodsProvider.java b/server-spi/src/main/java/org/keycloak/storage/user/UserCountMethodsProvider.java new file mode 100644 index 0000000000..b3485612e2 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/storage/user/UserCountMethodsProvider.java @@ -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 + * UserStorageProvider that supports count queries. + * + * By implementing this interface, count queries could be leveraged when querying users with some offset ({@code firstResult}). + * + *

    + * 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 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 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 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 params, Set 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 users, Set 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"); + } +} diff --git a/server-spi/src/main/java/org/keycloak/storage/user/UserQueryMethodsProvider.java b/server-spi/src/main/java/org/keycloak/storage/user/UserQueryMethodsProvider.java new file mode 100644 index 0000000000..9b638e6a83 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/storage/user/UserQueryMethodsProvider.java @@ -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 + * UserStorageProvider that supports complex user querying. You must + * implement this interface if you want to view and manage users from the administration console. + *

    + * 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 Bill Burke + * @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 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 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. + *

    + * If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE). + *

    + * 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 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. + *

    + * If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE). + *

    + * 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 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). + *

    + * Valid parameters are: + *

      + *
    • {@link UserModel#FIRST_NAME} - first name (case insensitive string)
    • + *
    • {@link UserModel#LAST_NAME} - last name (case insensitive string)
    • + *
    • {@link UserModel#EMAIL} - email (case insensitive string)
    • + *
    • {@link UserModel#USERNAME} - username (case insensitive string)
    • + *
    • {@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)
    • + *
    • {@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)
    • + *
    • {@link UserModel#IDP_ALIAS} - search only for users that have a federated identity + * from idp with the given alias configured (case sensitive string)
    • + *
    • {@link UserModel#IDP_USER_ID} - search for users with federated identity with + * the given userId (case sensitive string)
    • + *
    + *

    + * 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 searchForUserStream(RealmModel realm, Map 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). + *

    + * Valid parameters are: + *

      + *
    • {@link UserModel#FIRST_NAME} - first name (case insensitive string)
    • + *
    • {@link UserModel#LAST_NAME} - last name (case insensitive string)
    • + *
    • {@link UserModel#EMAIL} - email (case insensitive string)
    • + *
    • {@link UserModel#USERNAME} - username (case insensitive string)
    • + *
    • {@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)
    • + *
    • {@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)
    • + *
    • {@link UserModel#IDP_ALIAS} - search only for users that have a federated identity + * from idp with the given alias configured (case sensitive string)
    • + *
    • {@link UserModel#IDP_USER_ID} - search for users with federated identity with + * the given userId (case sensitive string)
    • + *
    + *

    + * 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 searchForUserStream(RealmModel realm, Map 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 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 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 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 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 searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue); +} diff --git a/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java b/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java index f2f5f34a3e..c1f020541b 100644 --- a/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java +++ b/server-spi/src/main/java/org/keycloak/storage/user/UserQueryProvider.java @@ -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 - * UserStorageProvider that supports complex user querying. You must - * implement this interface if you want to view and manage users from the administration console. - *

    - * 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 Bill Burke - * @version $Revision: 1 $ + * UserStorageProvider that supports complex user querying. + * + * 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 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 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 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 params, Set 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 users, Set 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 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 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. - *

    - * If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE). - *

    - * 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 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. - *

    - * If possible, implementations should treat the parameter values as partial match patterns (i.e. in RDMBS terms use LIKE). - *

    - * 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 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). - *

    - * Valid parameters are: - *

      - *
    • {@link UserModel#FIRST_NAME} - first name (case insensitive string)
    • - *
    • {@link UserModel#LAST_NAME} - last name (case insensitive string)
    • - *
    • {@link UserModel#EMAIL} - email (case insensitive string)
    • - *
    • {@link UserModel#USERNAME} - username (case insensitive string)
    • - *
    • {@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)
    • - *
    • {@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)
    • - *
    • {@link UserModel#IDP_ALIAS} - search only for users that have a federated identity - * from idp with the given alias configured (case sensitive string)
    • - *
    • {@link UserModel#IDP_USER_ID} - search for users with federated identity with - * the given userId (case sensitive string)
    • - *
    - * - * 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 searchForUserStream(RealmModel realm, Map 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). - *

    - * Valid parameters are: - *

      - *
    • {@link UserModel#FIRST_NAME} - first name (case insensitive string)
    • - *
    • {@link UserModel#LAST_NAME} - last name (case insensitive string)
    • - *
    • {@link UserModel#EMAIL} - email (case insensitive string)
    • - *
    • {@link UserModel#USERNAME} - username (case insensitive string)
    • - *
    • {@link UserModel#EMAIL_VERIFIED} - search only for users with verified/non-verified email (true/false)
    • - *
    • {@link UserModel#ENABLED} - search only for enabled/disabled users (true/false)
    • - *
    • {@link UserModel#IDP_ALIAS} - search only for users that have a federated identity - * from idp with the given alias configured (case sensitive string)
    • - *
    • {@link UserModel#IDP_USER_ID} - search for users with federated identity with - * the given userId (case sensitive string)
    • - *
    - * - * 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 searchForUserStream(RealmModel realm, Map 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 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 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 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 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 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 diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserPropertyFileStorage.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserPropertyFileStorage.java index 0266a732da..ff01028878 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserPropertyFileStorage.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserPropertyFileStorage.java @@ -225,10 +225,14 @@ public class UserPropertyFileStorage implements UserLookupProvider, UserStorageP public Stream searchForUserStream(RealmModel realm, Map 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 p = Boolean.valueOf(attributes.getOrDefault(UserModel.EXACT, Boolean.FALSE.toString())) - ? username -> username.equals(search) - : username -> username.contains(search); + Predicate 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); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPCountQueryTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPCountQueryTest.java new file mode 100644 index 0000000000..832bbe9425 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPCountQueryTest.java @@ -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)); + } +}