diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java index 3dc1ca97b1..88d91c112f 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java @@ -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 getAll( - @QueryParam("domain-name") String domainName + List 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 search( + @QueryParam("search") String search, + @QueryParam("exact") Boolean exact, + @QueryParam("first") Integer first, + @QueryParam("max") Integer max ); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java index 91a26f614e..ee4909cd19 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java @@ -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 { diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index db7556cc82..5609586409 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -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 getAllStream() { - TypedQuery query = em.createNamedQuery("getByRealm", OrganizationEntity.class); - + public Stream getAllStream(String search, Boolean exact, Integer first, Integer max) { + TypedQuery 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 diff --git a/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java index 9685d60470..8b7be223dd 100644 --- a/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -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 getAllStream(); + default Stream 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 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. diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java index 4ab681507b..933966c063 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java @@ -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 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}") diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java index e2dff35819..8c501c628f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java @@ -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 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; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java index 12c6020bd6..555dd957fc 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java @@ -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 existing = testRealm().organizations().getAll(null); + List 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 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 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 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 {