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}") @Path("{id}")
OrganizationResource get(@PathParam("id") String id); OrganizationResource get(@PathParam("id") String id);
/**
* Return all organizations in the realm.
*
* @return a list containing the organizations.
*/
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
List<OrganizationRepresentation> getAll( List<OrganizationRepresentation> getAll();
@QueryParam("domain-name") String domainName
/**
* 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") @Table(name="ORG")
@Entity @Entity
@NamedQueries({ @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 { public class OrganizationEntity {

View file

@ -18,6 +18,7 @@
package org.keycloak.organization.jpa; package org.keycloak.organization.jpa;
import static org.keycloak.models.OrganizationModel.USER_ORGANIZATION_ATTRIBUTE; 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 static org.keycloak.utils.StreamsUtil.closing;
import java.util.Set; import java.util.Set;
@ -150,12 +151,21 @@ public class JpaOrganizationProvider implements OrganizationProvider {
} }
@Override @Override
public Stream<OrganizationModel> getAllStream() { public Stream<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max) {
TypedQuery<OrganizationEntity> query = em.createNamedQuery("getByRealm", OrganizationEntity.class); 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()); 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 @Override

View file

@ -20,6 +20,7 @@ import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelException;
import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider; import org.keycloak.provider.Provider;
@ -56,10 +57,26 @@ public interface OrganizationProvider extends Provider {
OrganizationModel getByDomainName(String domainName); OrganizationModel getByDomainName(String domainName);
/** /**
* Returns the organizations of the given realm as a stream. * Returns all organizations in the realm.
* @return Stream of the organizations. Never returns {@code null}. *
* @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. * 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; package org.keycloak.organization.admin.resource;
import java.util.Comparator;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.Objects; import java.util.Objects;
@ -25,6 +26,7 @@ import java.util.stream.Stream;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT; 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.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationDomainModel;
@ -85,17 +88,15 @@ public class OrganizationResource {
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Operation( summary = "Return a paginated list of organizations filtered according to the specified parameters")
public Stream<OrganizationRepresentation> search( 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(); auth.realm().requireManageRealm();
if (StringUtil.isBlank(domainName)) { return provider.getAllStream(search, exact, first, max).map(this::toRepresentation);
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));
}
} }
@Path("{id}") @Path("{id}")

View file

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

View file

@ -18,21 +18,29 @@
package org.keycloak.testsuite.organization.admin; package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo; 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.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail; 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.ForbiddenException;
import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status; 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.hamcrest.Matchers;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.Keycloak;
@ -99,31 +107,77 @@ public class OrganizationTest extends AbstractOrganizationTest {
expected.add(createOrganization("kc.org." + i)); expected.add(createOrganization("kc.org." + i));
} }
List<OrganizationRepresentation> existing = testRealm().organizations().getAll(null); List<OrganizationRepresentation> existing = testRealm().organizations().getAll();
assertFalse(existing.isEmpty()); assertFalse(existing.isEmpty());
assertThat(expected, containsInAnyOrder(existing.toArray())); assertThat(expected, containsInAnyOrder(existing.toArray()));
} }
@Test @Test
public void testGetByDomain() { public void testSearch() {
// create some organizations with a domain already set. // create some organizations with different names and domains.
for (int i = 0; i < 5; i++) { createOrganization("acme", "acme.org", "acme.net");
createOrganization("test-org-" + i, "testorg" + i + ".org"); 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. // 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().getAll("testorg2.org"); List<OrganizationRepresentation> existing = testRealm().organizations().search("wayne-industries", true, 0, 10);
assertEquals(1, existing.size()); assertThat(existing, hasSize(1));
OrganizationRepresentation orgRep = existing.get(0); OrganizationRepresentation orgRep = existing.get(0);
assertEquals("test-org-2", orgRep.getName()); assertThat(orgRep.getName(), is(equalTo("wayne-industries")));
assertEquals(1, orgRep.getDomains().size()); assertThat(orgRep.getDomains(), hasSize(2));
OrganizationDomainRepresentation domainRep = orgRep.getDomains().iterator().next(); assertThat(orgRep.getDomain("wayneind.com"), not(nullValue()));
assertEquals("testorg2.org", domainRep.getName()); assertThat(orgRep.getDomain("wayneind-gotham.com"), not(nullValue()));
assertFalse(domainRep.isVerified());
// search for an organization with an non-existent domain. existing = testRealm().organizations().search("gtbank.net", true, 0, 10);
existing = testRealm().organizations().getAll("someother.org"); assertThat(existing, hasSize(1));
assertEquals(0, existing.size()); 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 @Test
@ -286,10 +340,10 @@ public class OrganizationTest extends AbstractOrganizationTest {
//search for org //search for org
try { try {
realmUserResource.organizations().getAll("testOrg.org"); realmUserResource.organizations().search("testOrg.org", true, 0, 1);
fail("Expected ForbiddenException"); fail("Expected ForbiddenException");
} catch (ForbiddenException expected) {} } catch (ForbiddenException expected) {}
assertThat(realmAdminResource.organizations().getAll("testOrg.org"), Matchers.notNullValue()); assertThat(realmAdminResource.organizations().search("testOrg.org", true, 0, 1), Matchers.notNullValue());
//get org //get org
try { try {