28843 - Introduce filtered (and paginated) searches for organizations
Closes #28843 Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
parent
8fa2890f68
commit
bfabc291cc
7 changed files with 163 additions and 54 deletions
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue