28843 - Introduce filtered (and paginated) searches for organizations

Closes #28843

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2024-04-24 11:58:30 -03:00 committed by Pedro Igor
parent 8fa2890f68
commit bfabc291cc
7 changed files with 163 additions and 54 deletions

View file

@ -39,9 +39,32 @@ public interface OrganizationsResource {
@Path("{id}")
OrganizationResource get(@PathParam("id") String id);
/**
* Return all organizations in the realm.
*
* @return a list containing the organizations.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
List<OrganizationRepresentation> getAll(
@QueryParam("domain-name") String domainName
List<OrganizationRepresentation> getAll();
/**
* Return all organizations that match the specified filters.
*
* @param search a {@code String} representing either an organization name or domain.
* @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.
* @return a list containing the matched organizations.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
List<OrganizationRepresentation> search(
@QueryParam("search") String search,
@QueryParam("exact") Boolean exact,
@QueryParam("first") Integer first,
@QueryParam("max") Integer max
);
}

View file

@ -35,7 +35,11 @@ import jakarta.persistence.Table;
@Table(name="ORG")
@Entity
@NamedQueries({
@NamedQuery(name="getByRealm", query="select o from OrganizationEntity o where o.realmId = :realmId")
@NamedQuery(name="getByRealm", query="select o from OrganizationEntity o where o.realmId = :realmId order by o.name ASC"),
@NamedQuery(name="getByNameOrDomain", query="select distinct o from OrganizationEntity o inner join OrganizationDomainEntity d ON o.id = d.organization.id" +
" where o.realmId = :realmId AND (o.name = :search OR d.name = :search) order by o.name ASC"),
@NamedQuery(name="getByNameOrDomainContained", query="select distinct o from OrganizationEntity o inner join OrganizationDomainEntity d ON o.id = d.organization.id" +
" where o.realmId = :realmId AND (lower(o.name) like concat('%',:search,'%') OR d.name like concat('%',:search,'%')) order by o.name ASC")
})
public class OrganizationEntity {

View file

@ -18,6 +18,7 @@
package org.keycloak.organization.jpa;
import static org.keycloak.models.OrganizationModel.USER_ORGANIZATION_ATTRIBUTE;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing;
import java.util.Set;
@ -150,12 +151,21 @@ public class JpaOrganizationProvider implements OrganizationProvider {
}
@Override
public Stream<OrganizationModel> getAllStream() {
TypedQuery<OrganizationEntity> query = em.createNamedQuery("getByRealm", OrganizationEntity.class);
public Stream<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max) {
TypedQuery<OrganizationEntity> query;
if (StringUtil.isBlank(search)) {
query = em.createNamedQuery("getByRealm", OrganizationEntity.class);
} else if (Boolean.TRUE.equals(exact)) {
query = em.createNamedQuery("getByNameOrDomain", OrganizationEntity.class);
query.setParameter("search", search);
} else {
query = em.createNamedQuery("getByNameOrDomainContained", OrganizationEntity.class);
query.setParameter("search", search.toLowerCase());
}
query.setParameter("realmId", realm.getId());
return closing(query.getResultStream().map(entity -> new OrganizationAdapter(realm, entity, this)));
return closing(paginateQuery(query, first, max).getResultStream()
.map(entity -> new OrganizationAdapter(realm, entity, this)));
}
@Override

View file

@ -20,6 +20,7 @@ import java.util.Set;
import java.util.stream.Stream;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelException;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;
@ -56,10 +57,26 @@ public interface OrganizationProvider extends Provider {
OrganizationModel getByDomainName(String domainName);
/**
* Returns the organizations of the given realm as a stream.
* @return Stream of the organizations. Never returns {@code null}.
* Returns all organizations in the realm.
*
* @return a {@link Stream} of the realm's organizations.
*/
Stream<OrganizationModel> getAllStream();
default Stream<OrganizationModel> getAllStream() {
return getAllStream("", null, -1, -1);
}
/**
* Returns the organizations in the realm using the specified filters.
*
* @param search a {@code String} representing either an organization name or domain.
* @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.
* @return a {@link Stream} of the matched organizations. Never returns {@code null}.
*/
Stream<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max);
/**
* Removes the given organization from the realm together with the data associated with it, e.g. its members etc.

View file

@ -17,6 +17,7 @@
package org.keycloak.organization.admin.resource;
import java.util.Comparator;
import java.util.Optional;
import java.util.Set;
import java.util.Objects;
@ -25,6 +26,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.POST;
import jakarta.ws.rs.PUT;
@ -35,6 +37,7 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationDomainModel;
@ -85,17 +88,15 @@ public class OrganizationResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation( summary = "Return a paginated list of organizations filtered according to the specified parameters")
public Stream<OrganizationRepresentation> search(
@Parameter(description = "A String representing an organization internet domain") @QueryParam("domain-name") String domainName
) {
@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
) {
auth.realm().requireManageRealm();
if (StringUtil.isBlank(domainName)) {
return provider.getAllStream().map(this::toRepresentation);
} else {
// search for the organization associated with the given domain
OrganizationModel org = provider.getByDomainName(domainName.trim());
return org == null ? Stream.empty() : Stream.of(toRepresentation(org));
}
return provider.getAllStream(search, exact, first, max).map(this::toRepresentation);
}
@Path("{id}")

View file

@ -21,6 +21,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.util.List;
import java.util.function.Function;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
@ -43,8 +44,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
protected String organizationName = "neworg";
protected String memberEmail = "jdoe@neworg.org";
protected String memberPassword = "password";
protected KcOidcBrokerConfiguration bc = new KcOidcBrokerConfiguration() {
protected Function<String, KcOidcBrokerConfiguration> brokerConfigFunction = name -> new KcOidcBrokerConfiguration() {
@Override
public String consumerRealmName() {
return TEST_REALM_NAME;
@ -73,10 +73,12 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
@Override
public String getIDPAlias() {
return "org-identity-provider";
return name + "-identity-provider";
}
};
protected KcOidcBrokerConfiguration bc = brokerConfigFunction.apply(organizationName);
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
testRealm.getClients().addAll(bc.createConsumerClients());
@ -98,9 +100,8 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
return createOrganization(name, name + ".org");
}
protected OrganizationRepresentation createOrganization(String name, String orgDomain) {
protected OrganizationRepresentation createOrganization(String name, String... orgDomain) {
OrganizationRepresentation org = createRepresentation(name, orgDomain);
String id;
try (Response response = testRealm().organizations().create(org)) {
@ -108,23 +109,22 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
id = ApiUtil.getCreatedId(response);
}
testRealm().organizations().get(id).identityProvider().create(bc.setUpIdentityProvider()).close();
testRealm().organizations().get(id).identityProvider().create(brokerConfigFunction.apply(name).setUpIdentityProvider()).close();
org = testRealm().organizations().get(id).toRepresentation();
getCleanup().addCleanup(() -> testRealm().organizations().get(id).delete().close());
return org;
}
protected OrganizationRepresentation createRepresentation(String name, String orgDomain) {
protected OrganizationRepresentation createRepresentation(String name, String... orgDomains) {
OrganizationRepresentation org = new OrganizationRepresentation();
org.setName(name);
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();
domainRep.setName(orgDomain);
org.addDomain(domainRep);
for (String orgDomain : orgDomains) {
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();
domainRep.setName(orgDomain);
org.addDomain(domainRep);
}
return org;
}

View file

@ -18,21 +18,29 @@
package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
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.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.admin.client.Keycloak;
@ -99,31 +107,77 @@ public class OrganizationTest extends AbstractOrganizationTest {
expected.add(createOrganization("kc.org." + i));
}
List<OrganizationRepresentation> existing = testRealm().organizations().getAll(null);
List<OrganizationRepresentation> existing = testRealm().organizations().getAll();
assertFalse(existing.isEmpty());
assertThat(expected, containsInAnyOrder(existing.toArray()));
}
@Test
public void testGetByDomain() {
// create some organizations with a domain already set.
for (int i = 0; i < 5; i++) {
createOrganization("test-org-" + i, "testorg" + i + ".org");
}
public void testSearch() {
// create some organizations with different names and domains.
createOrganization("acme", "acme.org", "acme.net");
createOrganization("Gotham-Bank", "gtbank.com", "gtbank.net");
createOrganization("wayne-industries", "wayneind.com", "wayneind-gotham.com");
createOrganization("TheWave", "the-wave.br");
// search for an organization with an existing domain.
List<OrganizationRepresentation> existing = testRealm().organizations().getAll("testorg2.org");
assertEquals(1, existing.size());
// test exact search by name (e.g. 'wayne-industries'), e-mail (e.g. 'gtbank.net'), and no result (e.g. 'nonexistent.com')
List<OrganizationRepresentation> existing = testRealm().organizations().search("wayne-industries", true, 0, 10);
assertThat(existing, hasSize(1));
OrganizationRepresentation orgRep = existing.get(0);
assertEquals("test-org-2", orgRep.getName());
assertEquals(1, orgRep.getDomains().size());
OrganizationDomainRepresentation domainRep = orgRep.getDomains().iterator().next();
assertEquals("testorg2.org", domainRep.getName());
assertFalse(domainRep.isVerified());
assertThat(orgRep.getName(), is(equalTo("wayne-industries")));
assertThat(orgRep.getDomains(), hasSize(2));
assertThat(orgRep.getDomain("wayneind.com"), not(nullValue()));
assertThat(orgRep.getDomain("wayneind-gotham.com"), not(nullValue()));
// search for an organization with an non-existent domain.
existing = testRealm().organizations().getAll("someother.org");
assertEquals(0, existing.size());
existing = testRealm().organizations().search("gtbank.net", true, 0, 10);
assertThat(existing, hasSize(1));
orgRep = existing.get(0);
assertThat(orgRep.getName(), is(equalTo("Gotham-Bank")));
assertThat(orgRep.getDomains(), hasSize(2));
assertThat(orgRep.getDomain("gtbank.com"), not(nullValue()));
assertThat(orgRep.getDomain("gtbank.net"), not(nullValue()));
existing = testRealm().organizations().search("nonexistent.org", true, 0, 10);
assertThat(existing, is(empty()));
// partial search matching name (e.g. 'wa' matching 'wayne-industries', and 'TheWave')
existing = testRealm().organizations().search("wa", false, 0, 10);
assertThat(existing, hasSize(2));
List<String> orgNames = existing.stream().map(OrganizationRepresentation::getName).collect(Collectors.toList());
assertThat(orgNames, containsInAnyOrder("wayne-industries", "TheWave"));
// partial search matching domain (e.g. '.net', matching acme and gotham-bank)
existing = testRealm().organizations().search(".net", false, 0, 10);
assertThat(existing, hasSize(2));
orgNames = existing.stream().map(OrganizationRepresentation::getName).collect(Collectors.toList());
assertThat(orgNames, containsInAnyOrder("Gotham-Bank", "acme"));
// partial search matching both a domain and org name, on two different orgs (e.g. 'gotham' matching 'Gotham-Bank' by name and 'wayne-industries' by domain)
existing = testRealm().organizations().search("gotham", false, 0, 10);
assertThat(existing, hasSize(2));
orgNames = existing.stream().map(OrganizationRepresentation::getName).collect(Collectors.toList());
assertThat(orgNames, containsInAnyOrder("Gotham-Bank", "wayne-industries"));
// partial search matching no org (e.g. nonexistent)
existing = testRealm().organizations().search("nonexistent", false, 0, 10);
assertThat(existing, is(empty()));
// paginated search - create more orgs, try to fetch them all in paginated form.
for (int i = 0; i < 10; i++) {
createOrganization("ztest-" + i);
}
existing = testRealm().organizations().search("", false, 0, 10);
// first page should have 10 results.
assertThat(existing, hasSize(10));
orgNames = existing.stream().map(OrganizationRepresentation::getName).collect(Collectors.toList());
assertThat(orgNames, containsInAnyOrder("Gotham-Bank", "TheWave", "acme", "wayne-industries", "ztest-0",
"ztest-1", "ztest-2", "ztest-3", "ztest-4", "ztest-5"));
existing = testRealm().organizations().search("", false, 10, 10);
// second page should have 4 results.
assertThat(existing, hasSize(4));
orgNames = existing.stream().map(OrganizationRepresentation::getName).collect(Collectors.toList());
assertThat(orgNames, containsInAnyOrder("ztest-6", "ztest-7", "ztest-8", "ztest-9"));
}
@Test
@ -286,10 +340,10 @@ public class OrganizationTest extends AbstractOrganizationTest {
//search for org
try {
realmUserResource.organizations().getAll("testOrg.org");
realmUserResource.organizations().search("testOrg.org", true, 0, 1);
fail("Expected ForbiddenException");
} catch (ForbiddenException expected) {}
assertThat(realmAdminResource.organizations().getAll("testOrg.org"), Matchers.notNullValue());
assertThat(realmAdminResource.organizations().search("testOrg.org", true, 0, 1), Matchers.notNullValue());
//get org
try {