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 f112381646d..c42c1c1c791 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 @@ -40,7 +40,7 @@ public interface OrganizationsResource { OrganizationResource get(@PathParam("id") String id); /** - * Return all organizations in the realm. + * Returns all organizations in the realm. * * @return a list containing the organizations. */ @@ -49,7 +49,17 @@ public interface OrganizationsResource { List getAll(); /** - * Return all organizations that match the specified filters. + * Returns all organizations that match the specified filter. + * + * @param search a {@code String} representing either an organization name or domain. + * @return a list containing the matched organizations. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + List search(@QueryParam("search") String search); + + /** + * Returns 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. @@ -69,12 +79,30 @@ public interface OrganizationsResource { ); /** - * Return all organizations that match the specified filter. + * Returns all organizations that contain attributes matching the specified query. * - * @param search a {@code String} representing either an organization name or domain. - * @return a list containing the matched organizations. + * @param searchQuery a query to search for organization attributes, in the format 'key1:value2 key2:value2'. + * @return a list containing the organizations that match the attribute query. */ @GET @Produces(MediaType.APPLICATION_JSON) - List search(@QueryParam("search") String search); + List searchByAttribute( + @QueryParam("q") String searchQuery + ); + + /** + * Returns all organizations that contain attributes matching the specified query. + * + * @param searchQuery a query to search for organization attributes, in the format 'key1:value2 key2:value2'. + * @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 organizations that match the attribute query. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + List searchByAttribute( + @QueryParam("q") String searchQuery, + @QueryParam("first") Integer first, + @QueryParam("max") Integer max + ); } 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 d51a243a293..9e87342b8db 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 @@ -21,7 +21,9 @@ import static org.keycloak.models.OrganizationModel.ORGANIZATION_ATTRIBUTE; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.utils.StreamsUtil.closing; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -29,6 +31,11 @@ import java.util.stream.Stream; import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.GroupModel; @@ -43,6 +50,8 @@ import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserProvider; +import org.keycloak.models.jpa.entities.GroupAttributeEntity; +import org.keycloak.models.jpa.entities.GroupEntity; import org.keycloak.models.jpa.entities.OrganizationDomainEntity; import org.keycloak.models.jpa.entities.OrganizationEntity; import org.keycloak.models.utils.KeycloakModelUtils; @@ -175,6 +184,32 @@ public class JpaOrganizationProvider implements OrganizationProvider { .map(entity -> new OrganizationAdapter(realm, entity, this))); } + @Override + public Stream getAllStream(Map attributes, Integer first, Integer max) { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(OrganizationEntity.class); + Root org = query.from(OrganizationEntity.class); + Root group = query.from(GroupEntity.class); + + List predicates = new ArrayList<>(); + predicates.add(builder.equal(org.get("realmId"), realm.getId())); + predicates.add(builder.equal(org.get("groupId"), group.get("id"))); + + for (Map.Entry entry : attributes.entrySet()) { + if (StringUtil.isNotBlank(entry.getKey())) { + Join groupJoin = group.join("attributes"); + Predicate attrNamePredicate = builder.equal(groupJoin.get("name"), entry.getKey()); + Predicate attrValuePredicate = builder.equal(groupJoin.get("value"), entry.getValue()); + predicates.add(builder.and(attrNamePredicate, attrValuePredicate)); + } + } + + Predicate finalPredicate = builder.and(predicates.toArray(new Predicate[0])); + TypedQuery typedQuery = em.createQuery(query.select(org).where(finalPredicate)); + return closing(paginateQuery(typedQuery, first, max).getResultStream()) + .map(entity -> new OrganizationAdapter(realm, entity, this)); + } + @Override public Stream getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) { throwExceptionIfObjectIsNull(organization, "Organization"); 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 ea6a3205efb..acf660e40dc 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 @@ -17,6 +17,7 @@ package org.keycloak.organization; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Stream; import org.keycloak.models.IdentityProviderModel; @@ -67,7 +68,7 @@ public interface OrganizationProvider extends Provider { } /** - * Returns the organizations in the realm using the specified filters. + * Returns all organizations in the realm filtered according to the specified parameters. * * @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. @@ -79,6 +80,16 @@ public interface OrganizationProvider extends Provider { */ Stream getAllStream(String search, Boolean exact, Integer first, Integer max); + /** + * Returns all organizations in the realm filtered according to the specified parameters. + * + * @param attributes a {@code Map} containig the attributes (name/value) that must match organization attributes. + * @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 getAllStream(Map attributes, 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 4649b97f33d..129b357cc86 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 @@ -19,6 +19,7 @@ package org.keycloak.organization.admin.resource; import static java.util.Optional.ofNullable; +import java.util.Map; import java.util.Set; import java.util.Objects; import java.util.stream.Collectors; @@ -39,6 +40,8 @@ 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.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.resteasy.reactive.NoCache; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationModel; @@ -46,8 +49,10 @@ import org.keycloak.organization.OrganizationProvider; import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.services.ErrorResponse; +import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.AdminEventBuilder; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.utils.SearchQueryUtils; import org.keycloak.utils.StringUtil; @Provider @@ -86,17 +91,39 @@ public class OrganizationResource { return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build(); } + /** + * Returns a stream of organizations, filtered according to query parameters. + * + * @param search a {@code String} representing either an organization name or domain. + * @param searchQuery a query to search for organization attributes, in the format 'key1:value2 key2:value2'. + * @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 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 {@code Stream} of matched organizations. + */ @GET @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS) @Operation( summary = "Return a paginated list of organizations filtered according to the specified parameters") public Stream search( @Parameter(description = "A String representing either an organization name or domain") @QueryParam("search") String search, + @Parameter(description = "A query to search for custom attributes, in the format 'key1:value2 key2:value2'") @QueryParam("q") String searchQuery, @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); + + // check if are searching orgs by attribute. + if(StringUtil.isNotBlank(searchQuery)) { + Map attributes = SearchQueryUtils.getFields(searchQuery); + return provider.getAllStream(attributes, first, max).map(this::toRepresentation); + } else { + return provider.getAllStream(search, exact, first, max).map(this::toRepresentation); + } } @Path("{id}") diff --git a/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java b/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java index 16ad437e084..557be863587 100644 --- a/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java +++ b/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java @@ -55,6 +55,7 @@ public class KeycloakOpenAPI { public static final String ROOT = "Root"; public static final String SCOPE_MAPPINGS = "Scope Mappings"; public static final String USERS = "Users"; + public static final String ORGANIZATIONS = "Organizations"; private Tags() { } } 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 dd533564aa5..31777690e88 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 @@ -34,6 +34,7 @@ import static org.junit.Assert.fail; import static org.keycloak.testsuite.admin.group.GroupSearchTest.buildSearchQuery; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.stream.Collectors; @@ -174,6 +175,72 @@ public class OrganizationTest extends AbstractOrganizationTest { assertThat(orgNames, containsInAnyOrder("ztest-6", "ztest-7", "ztest-8", "ztest-9")); } + @Test + public void testSearchByAttributes() { + List expected = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + expected.add(createOrganization("testorg." + i)); + } + + // set attributes to the orgs. + OrganizationRepresentation orgRep = expected.get(0); + orgRep.singleAttribute("attr1", "value1"); + try (Response response = testRealm().organizations().get(orgRep.getId()).update(orgRep)) { + assertThat(response.getStatus(), is(equalTo(Status.NO_CONTENT.getStatusCode()))); + } + + orgRep = expected.get(1); + orgRep.singleAttribute("attr1", "value1").singleAttribute("attr2", "value2"); + try (Response response = testRealm().organizations().get(orgRep.getId()).update(orgRep)) { + assertThat(response.getStatus(), is(equalTo(Status.NO_CONTENT.getStatusCode()))); + } + + orgRep = expected.get(2); + orgRep.singleAttribute("attr1", "value1").singleAttribute("attr3", "value3"); + try (Response response = testRealm().organizations().get(orgRep.getId()).update(orgRep)) { + assertThat(response.getStatus(), is(equalTo(Status.NO_CONTENT.getStatusCode()))); + } + + orgRep = expected.get(3); + orgRep.singleAttribute("attr2", "value2"); + try (Response response = testRealm().organizations().get(orgRep.getId()).update(orgRep)) { + assertThat(response.getStatus(), is(equalTo(Status.NO_CONTENT.getStatusCode()))); + } + + // search for "attr1:value1" - should match testorg.0, testorg.1, and testorg.2 + List fetchedOrgs = testRealm().organizations().searchByAttribute("attr1:value1"); + fetchedOrgs.sort(Comparator.comparing(OrganizationRepresentation::getName)); + assertThat(fetchedOrgs, hasSize(3)); + assertThat(fetchedOrgs.get(0).getName(), is(equalTo(expected.get(0).getName()))); + assertThat(fetchedOrgs.get(1).getName(), is(equalTo(expected.get(1).getName()))); + assertThat(fetchedOrgs.get(2).getName(), is(equalTo(expected.get(2).getName()))); + + // search for "attr2:value2" - should match testorg.1 and testorg.3 + fetchedOrgs = testRealm().organizations().searchByAttribute("attr2:value2"); + fetchedOrgs.sort(Comparator.comparing(OrganizationRepresentation::getName)); + assertThat(fetchedOrgs, hasSize(2)); + assertThat(fetchedOrgs.get(0).getName(), is(equalTo(expected.get(1).getName()))); + assertThat(fetchedOrgs.get(1).getName(), is(equalTo(expected.get(3).getName()))); + + // search for "attr3:value3" - should match only testorg.2 + fetchedOrgs = testRealm().organizations().searchByAttribute("attr3:value3"); + assertThat(fetchedOrgs, hasSize(1)); + assertThat(fetchedOrgs.get(0).getName(), is(equalTo(expected.get(2).getName()))); + + // search for both "attr1:value1 attr2:value2" - should match only testorg.1 + fetchedOrgs = testRealm().organizations().searchByAttribute("attr1:value1 attr2:value2"); + assertThat(fetchedOrgs, hasSize(1)); + assertThat(fetchedOrgs.get(0).getName(), is(equalTo(expected.get(1).getName()))); + + // search for both "attr2:value2 attr3:value3" - not org has both of these attributes at the same time. + fetchedOrgs = testRealm().organizations().searchByAttribute("attr2:value2 attr3:value3"); + assertThat(fetchedOrgs, hasSize(0)); + + // search for "anything:anyvalue" - should again match no org because no org has this attribute. + fetchedOrgs = testRealm().organizations().searchByAttribute("anything:anyvalue"); + assertThat(fetchedOrgs, hasSize(0)); + } + @Test public void testDelete() { OrganizationRepresentation expected = createOrganization(); @@ -227,6 +294,7 @@ public class OrganizationTest extends AbstractOrganizationTest { updated = organization.toRepresentation(); assertEquals(0, updated.getAttributes().size()); + } @Test