Introduce filtered (and paginated) search for organization members

Closes #28844

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2024-04-26 19:52:56 -03:00 committed by Pedro Igor
parent 3e16af8c0f
commit 45e5e6cbbf
13 changed files with 246 additions and 14 deletions

View file

@ -25,6 +25,7 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.representations.idm.OrganizationRepresentation;
@ -36,10 +37,35 @@ public interface OrganizationMembersResource {
@Consumes(MediaType.APPLICATION_JSON)
Response addMember(String userId);
/**
* Return all members in the organization.
*
* @return a list containing the organization members.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
List<UserRepresentation> getAll();
/**
* Return all organization members that match the specified filters.
*
* @param search a {@code String} representing either a member's username, e-mail, first name, or last name.
* @param exact if {@code true}, the members will be searched using exact match for the {@code search} param - i.e.
* at least one of the username main attributes must match exactly the {@code search} param. If false,
* the method returns all members with at least one main attribute partially matching the {@code search} param.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @return a list containing the matched organization members.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
List<UserRepresentation> search(
@QueryParam("search") String search,
@QueryParam("exact") Boolean exact,
@QueryParam("first") Integer first,
@QueryParam("max") Integer max
);
@Path("{id}/organization")
@GET
@Produces(MediaType.APPLICATION_JSON)

View file

@ -55,8 +55,8 @@ public interface OrganizationsResource {
* @param exact if {@code true}, the organizations will be searched using exact match for the {@code search} param - i.e.
* either the organization name or one of its domains must match exactly the {@code search} param. If false,
* the method returns all organizations whose name or (domains) partially match the {@code search} param.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @param first the position of the first result to be processed (pagination offset). Ignored if negative or {@code null}.
* @param max the maximum number of results to be returned. Ignored if negative or {@code null}.
* @return a list containing the matched organizations.
*/
@GET

View file

@ -472,6 +472,11 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
return getDelegate().getGroupMembersStream(realm, group, firstResult, maxResults);
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, String search, Boolean exact, Integer firstResult, Integer maxResults) {
return getDelegate().getGroupMembersStream(realm, group, search, exact, firstResult, maxResults);
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group) {
return getDelegate().getGroupMembersStream(realm, group);

View file

@ -62,6 +62,7 @@ import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.keycloak.storage.jpa.JpaHashUtils;
import org.keycloak.utils.StringUtil;
import java.util.ArrayList;
import java.util.Collection;
@ -710,6 +711,23 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore {
return closing(paginateQuery(query, firstResult, maxResults).getResultStream().map(user -> new UserAdapter(session, realm, em, user)));
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, String search, Boolean exact, Integer first, Integer max) {
TypedQuery<UserEntity> query;
if (StringUtil.isBlank(search)) {
query = em.createNamedQuery("groupMembership", UserEntity.class);
} else if (Boolean.TRUE.equals(exact)) {
query = em.createNamedQuery("groupMembershipByUser", UserEntity.class);
query.setParameter("search", search);
} else {
query = em.createNamedQuery("groupMembershipByUserContained", UserEntity.class);
query.setParameter("search", search.toLowerCase());
}
query.setParameter("groupId", group.getId());
return closing(paginateQuery(query, first, max).getResultStream().map(user -> new UserAdapter(session, realm, em, user)));
}
@Override
public Stream<UserModel> getRoleMembersStream(RealmModel realm, RoleModel role, Integer firstResult, Integer maxResults) {
TypedQuery<UserEntity> query = em.createNamedQuery("usersInRole", UserEntity.class);

View file

@ -37,6 +37,11 @@ import java.io.Serializable;
@NamedQuery(name="userMemberOf", query="select m from UserGroupMembershipEntity m where m.user = :user and m.groupId = :groupId"),
@NamedQuery(name="userGroupMembership", query="select m from UserGroupMembershipEntity m where m.user = :user"),
@NamedQuery(name="groupMembership", query="select g.user from UserGroupMembershipEntity g where g.groupId = :groupId order by g.user.username"),
@NamedQuery(name="groupMembershipByUser", query="select g.user from UserGroupMembershipEntity g where g.groupId = :groupId and " +
"(g.user.username = :search or g.user.email = :search or g.user.firstName = :search or g.user.lastName = :search) order by g.user.username"),
@NamedQuery(name="groupMembershipByUserContained", query="select g.user from UserGroupMembershipEntity g where g.groupId = :groupId and " +
"(g.user.username like concat('%',:search,'%') or g.user.email like concat('%',:search,'%') or lower(g.user.firstName) like concat('%',:search,'%') or " +
"lower(g.user.lastName) like concat('%',:search,'%')) order by g.user.username"),
@NamedQuery(name="deleteUserGroupMembershipByRealm", query="delete from UserGroupMembershipEntity mapping where mapping.user IN (select u from UserEntity u where u.realmId=:realmId)"),
@NamedQuery(name="deleteUserGroupMembershipsByRealmAndLink", query="delete from UserGroupMembershipEntity mapping where mapping.user IN (select u from UserEntity u where u.realmId=:realmId and u.federationLink=:link)"),
@NamedQuery(name="deleteUserGroupMembershipsByGroup", query="delete from UserGroupMembershipEntity m where m.groupId = :groupId"),

View file

@ -171,11 +171,11 @@ public class JpaOrganizationProvider implements OrganizationProvider {
}
@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization) {
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
throwExceptionIfObjectIsNull(organization, "Organization");
GroupModel group = getOrganizationGroup(organization);
return userProvider.getGroupMembersStream(realm, group);
return userProvider.getGroupMembersStream(realm, group, search, exact, first, max);
}
@Override

View file

@ -65,6 +65,7 @@ import org.keycloak.models.utils.ReadOnlyUserModelDelegate;
import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.datastore.DefaultDatastoreProvider;
import org.keycloak.storage.federated.UserFederatedStorageProvider;
import org.keycloak.storage.federated.UserGroupMembershipFederatedStorage;
import org.keycloak.storage.managers.UserStorageSyncManager;
import org.keycloak.storage.user.ImportedUserValidation;
import org.keycloak.storage.user.UserBulkUpdateProvider;
@ -76,6 +77,8 @@ import org.keycloak.storage.user.UserRegistrationProvider;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.UserProfileDecorator;
import org.keycloak.userprofile.UserProfileMetadata;
import org.keycloak.utils.StreamsUtil;
import org.keycloak.utils.StringUtil;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -423,6 +426,36 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
return importValidation(realm, results);
}
@Override
public Stream<UserModel> getGroupMembersStream(final RealmModel realm, final GroupModel group, final String search,
final Boolean exact, final Integer firstResult, final Integer maxResults) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {
if (provider instanceof UserQueryMethodsProvider) {
return ((UserQueryMethodsProvider)provider).getGroupMembersStream(realm, group, search, exact, firstResultInQuery, maxResultsInQuery);
} else if (provider instanceof UserFederatedStorageProvider) {
// modify this if UserGroupMembershipFederatedStorage adds a getMembershipStream variant with search option.
return StreamsUtil.paginatedStream(((UserFederatedStorageProvider)provider).getMembershipStream(realm, group, null, null)
.map(id -> getUserById(realm, id))
.filter(user -> {
if (StringUtil.isBlank(search)) return true;
if (Boolean.TRUE.equals(exact)) {
return search.equals(user.getUsername()) || search.equals(user.getEmail())
|| search.equals(user.getFirstName()) || search.equals(user.getLastName());
} else {
return Optional.ofNullable(user.getUsername()).orElse("").toLowerCase().contains(search.toLowerCase()) ||
Optional.ofNullable(user.getEmail()).orElse("").toLowerCase().contains(search.toLowerCase()) ||
Optional.ofNullable(user.getFirstName()).orElse("").toLowerCase().contains(search.toLowerCase()) ||
Optional.ofNullable(user.getLastName()).orElse("").toLowerCase().contains(search.toLowerCase());
}
}), firstResultInQuery, maxResultsInQuery);
}
return Stream.empty();
}, realm, firstResult, maxResults);
return importValidation(realm, results);
}
@Override
public Stream<UserModel> getRoleMembersStream(final RealmModel realm, final RoleModel role, Integer firstResult, Integer maxResults) {
Stream<UserModel> results = query((provider, firstResultInQuery, maxResultsInQuery) -> {

View file

@ -72,8 +72,8 @@ public interface OrganizationProvider extends Provider {
* @param exact if {@code true}, the organizations will be searched using exact match for the {@code search} param - i.e.
* either the organization name or one of its domains must match exactly the {@code search} param. If false,
* the method returns all organizations whose name or (domains) partially match the {@code search} param.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @param first the position of the first result to be processed (pagination offset). Ignored if negative or {@code null}.
* @param max the maximum number of results to be returned. Ignored if negative or {@code null}.
* @return a {@link Stream} of the matched organizations. Never returns {@code null}.
*/
Stream<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max);
@ -103,12 +103,12 @@ public interface OrganizationProvider extends Provider {
boolean addMember(OrganizationModel organization, UserModel user);
/**
* Returns the members of a given {@link OrganizationModel}.
* Returns the members of a given {@link OrganizationModel} filtered according to the specified parameters.
*
* @param organization the organization
* @return Stream of the members. Never returns {@code null}.
*/
Stream<UserModel> getMembersStream(OrganizationModel organization);
Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max);
/**
* Returns the member of the {@link OrganizationModel} by its {@code id}.

View file

@ -21,8 +21,10 @@ import org.keycloak.models.GroupModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.utils.StringUtil;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
/**
@ -160,6 +162,45 @@ public interface UserQueryMethodsProvider {
*/
Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults);
/**
* Obtains users that belong to a specific group, filtered according to the search parameters.
*
* @param realm a reference to the realm.
* @param group a reference to the group.
* @param search the search string. It can represent either the user's username, e-mail, first name, or last name.
* @param exact a boolean indicating if the search should be exact or not. If {@code true}, it selects only users
* whose main attributes (username, e-mail, first name, or last name) exactly match the search string.
* If {@code false}, it selects the users whose main attributes partially match the search string.
* @param first the position of the first result to be processed (pagination offset). Ignored if negative or {@code null}.
* @param max the maximum number of results to be returned. Ignored if negative or {@code null}.
* @return a non-null {@link Stream} of filtered users that belong to the group.
*/
default Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, String search, Boolean exact,
Integer first, Integer max) {
Stream<UserModel> groupMembers = getGroupMembersStream(realm, group).filter(user -> {
if (StringUtil.isBlank(search)) return true;
if (Boolean.TRUE.equals(exact)) {
return search.equals(user.getUsername()) || search.equals(user.getEmail())
|| search.equals(user.getFirstName()) || search.equals(user.getLastName());
} else {
return Optional.ofNullable(user.getUsername()).orElse("").toLowerCase().contains(search.toLowerCase()) ||
Optional.ofNullable(user.getEmail()).orElse("").toLowerCase().contains(search.toLowerCase()) ||
Optional.ofNullable(user.getFirstName()).orElse("").toLowerCase().contains(search.toLowerCase()) ||
Optional.ofNullable(user.getLastName()).orElse("").toLowerCase().contains(search.toLowerCase());
}
});
// Copied over from StreamsUtil from server-spi-private which is not available here
if (first != null && first > 0) {
groupMembers = groupMembers.skip(first);
}
if (max != null && max >= 0) {
groupMembers = groupMembers.limit(max);
}
return groupMembers;
}
/**
* Obtains users that have the specified role.
*

View file

@ -21,6 +21,7 @@ import java.util.stream.Stream;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
@ -28,11 +29,15 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.ext.Provider;
import java.util.Objects;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.OrganizationModel;
@ -100,9 +105,15 @@ public class OrganizationMemberResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
public Stream<UserRepresentation> getMembers() {
@Operation( summary = "Return a paginated list of organization members filtered according to the specified parameters")
public Stream<UserRepresentation> search(
@Parameter(description = "A String representing either a member's username, e-mail, first name, or last name.") @QueryParam("search") String search,
@Parameter(description = "Boolean which defines whether the param 'search' must match exactly or not") @QueryParam("exact") Boolean exact,
@Parameter(description = "The position of the first result to be processed (pagination offset)") @QueryParam("first") @DefaultValue("0") Integer first,
@Parameter(description = "The maximum number of results to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max
) {
auth.realm().requireManageRealm();
return provider.getMembersStream(organization).map(this::toRepresentation);
return provider.getMembersStream(organization, search, exact, first, max).map(this::toRepresentation);
}
@Path("{id}")

View file

@ -91,9 +91,9 @@ public class OrganizationResource {
@Operation( summary = "Return a paginated list of organizations filtered according to the specified parameters")
public Stream<OrganizationRepresentation> search(
@Parameter(description = "A String representing either an organization name or domain") @QueryParam("search") String search,
@Parameter(description = "Boolean which defines whether the params \"search\" must match exactly or not") @QueryParam("exact") Boolean exact,
@Parameter(description = "The position of the first result to be returned (pagination offset)") @QueryParam("first") @DefaultValue("0") Integer first,
@Parameter(description = "The maximum number of results that are to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max
@Parameter(description = "Boolean which defines whether the param 'search' must match exactly or not") @QueryParam("exact") Boolean exact,
@Parameter(description = "The position of the first result to be processed (pagination offset)") @QueryParam("first") @DefaultValue("0") Integer first,
@Parameter(description = "The maximum number of results to be returned - defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max
) {
auth.realm().requireManageRealm();
return provider.getAllStream(search, exact, first, max).map(this::toRepresentation);

View file

@ -151,11 +151,17 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
}
protected UserRepresentation addMember(OrganizationResource organization, String email) {
return addMember(organization, email, null, null);
}
protected UserRepresentation addMember(OrganizationResource organization, String email, String firstName, String lastName) {
UserRepresentation expected = new UserRepresentation();
expected.setEmail(email);
expected.setUsername(expected.getEmail());
expected.setEnabled(true);
expected.setFirstName(firstName);
expected.setLastName(lastName);
Users.setPasswordFor(expected, memberPassword);
try (Response response = testRealm().users().create(expected)) {

View file

@ -18,6 +18,10 @@
package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
@ -145,7 +149,7 @@ public class OrganizationMemberTest extends AbstractOrganizationTest {
expected.add(addMember(organization, "member-" + i + "@neworg.org"));
}
List<UserRepresentation> existing = organization.members().getAll();;
List<UserRepresentation> existing = organization.members().getAll();
assertFalse(existing.isEmpty());
assertEquals(expected.size(), existing.size());
for (UserRepresentation expectedRep : expected) {
@ -228,4 +232,87 @@ public class OrganizationMemberTest extends AbstractOrganizationTest {
assertFalse(testRealm().groups().groups().stream().anyMatch(group -> group.getName().startsWith("kc.org.")));
}
@Test
public void testSearchMembers() {
// create test users, ordered by username (e-mail).
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
List<UserRepresentation> expected = new ArrayList<>();
expected.add(addMember(organization, "batwoman@neworg.org", "Katherine", "Kane"));
expected.add(addMember(organization, "brucewayne@neworg.org", "Bruce", "Wayne"));
expected.add(addMember(organization, "harveydent@neworg.org", "Harvey", "Dent"));
expected.add(addMember(organization, "marthaw@neworg.org", "Martha", "Wayne"));
expected.add(addMember(organization, "thejoker@neworg.org", "Jack", "White"));
// exact search - username/e-mail, first name, last name.
List<UserRepresentation> existing = organization.members().search("brucewayne@neworg.org", true, null, null);
assertThat(existing, hasSize(1));
assertThat(existing.get(0).getUsername(), is(equalTo("brucewayne@neworg.org")));
assertThat(existing.get(0).getEmail(), is(equalTo("brucewayne@neworg.org")));
assertThat(existing.get(0).getFirstName(), is(equalTo("Bruce")));
assertThat(existing.get(0).getLastName(), is(equalTo("Wayne")));
existing = organization.members().search("Harvey", true, null, null);
assertThat(existing, hasSize(1));
assertThat(existing.get(0).getUsername(), is(equalTo("harveydent@neworg.org")));
assertThat(existing.get(0).getEmail(), is(equalTo("harveydent@neworg.org")));
assertThat(existing.get(0).getFirstName(), is(equalTo("Harvey")));
assertThat(existing.get(0).getLastName(), is(equalTo("Dent")));
existing = organization.members().search("Wayne", true, null, null);
assertThat(existing, hasSize(2));
assertThat(existing.get(0).getUsername(), is(equalTo("brucewayne@neworg.org")));
assertThat(existing.get(1).getUsername(), is(equalTo("marthaw@neworg.org")));
existing = organization.members().search("Gordon", true, null, null);
assertThat(existing, is(empty()));
// partial search - partial e-mail should match all users.
existing = organization.members().search("neworg", false, null, null);
assertThat(existing, hasSize(5));
for (int i = 0; i < 5; i++) { // returned entries should also be ordered.
assertThat(expected.get(i).getId(), is(equalTo(expected.get(i).getId())));
assertThat(expected.get(i).getUsername(), is(equalTo(expected.get(i).getUsername())));
assertThat(expected.get(i).getEmail(), is(equalTo(expected.get(i).getEmail())));
assertThat(expected.get(i).getFirstName(), is(equalTo(expected.get(i).getFirstName())));
assertThat(expected.get(i).getLastName(), is(equalTo(expected.get(i).getLastName())));
}
// partial search using 'th' search string - should match 'Katherine' by name, 'Jack' by username/e-mail
// and 'Martha' either by username or first name.
existing = organization.members().search("th", false, null, null);
assertThat(existing, hasSize(3));
assertThat(existing.get(0).getUsername(), is(equalTo("batwoman@neworg.org")));
assertThat(existing.get(0).getFirstName(), is(equalTo("Katherine")));
assertThat(existing.get(1).getUsername(), is(equalTo("marthaw@neworg.org")));
assertThat(existing.get(1).getFirstName(), is(equalTo("Martha")));
assertThat(existing.get(2).getUsername(), is(equalTo("thejoker@neworg.org")));
assertThat(existing.get(2).getFirstName(), is(equalTo("Jack")));
// partial search using 'way' - should match both 'Bruce' (either by username or last name) and 'Martha' by last name.
existing = organization.members().search("way", false, null, null);
assertThat(existing, hasSize(2));
assertThat(existing.get(0).getUsername(), is(equalTo("brucewayne@neworg.org")));
assertThat(existing.get(0).getFirstName(), is(equalTo("Bruce")));
assertThat(existing.get(1).getUsername(), is(equalTo("marthaw@neworg.org")));
assertThat(existing.get(1).getFirstName(), is(equalTo("Martha")));
// partial search using with no match - e.g. 'nonexistent'.
existing = organization.members().search("nonexistent", false, null, null);
assertThat(existing, is(empty()));
// paginated search - try to fetch 3 users per page.
existing = organization.members().search("", false, 0, 3);
assertThat(existing, hasSize(3));
assertThat(existing.get(0).getUsername(), is(equalTo("batwoman@neworg.org")));
assertThat(existing.get(1).getUsername(), is(equalTo("brucewayne@neworg.org")));
assertThat(existing.get(2).getUsername(), is(equalTo("harveydent@neworg.org")));
existing = organization.members().search("", false, 3, 3);
assertThat(existing, hasSize(2));
assertThat(existing.get(0).getUsername(), is(equalTo("marthaw@neworg.org")));
assertThat(existing.get(1).getUsername(), is(equalTo("thejoker@neworg.org")));
}
}