Add ability to search organizations by attribute

Closes #29411

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2024-05-10 12:05:01 -03:00 committed by Pedro Igor
parent 6864ee0ead
commit ceed7bc120
6 changed files with 178 additions and 8 deletions

View file

@ -40,7 +40,7 @@ public interface OrganizationsResource {
OrganizationResource get(@PathParam("id") String id); OrganizationResource get(@PathParam("id") String id);
/** /**
* Return all organizations in the realm. * Returns all organizations in the realm.
* *
* @return a list containing the organizations. * @return a list containing the organizations.
*/ */
@ -49,7 +49,17 @@ public interface OrganizationsResource {
List<OrganizationRepresentation> getAll(); List<OrganizationRepresentation> 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<OrganizationRepresentation> 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 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. * @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. * @param searchQuery a query to search for organization attributes, in the format 'key1:value2 key2:value2'.
* @return a list containing the matched organizations. * @return a list containing the organizations that match the attribute query.
*/ */
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
List<OrganizationRepresentation> search(@QueryParam("search") String search); List<OrganizationRepresentation> 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<OrganizationRepresentation> searchByAttribute(
@QueryParam("q") String searchQuery,
@QueryParam("first") Integer first,
@QueryParam("max") Integer max
);
} }

View file

@ -21,7 +21,9 @@ import static org.keycloak.models.OrganizationModel.ORGANIZATION_ATTRIBUTE;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -29,6 +31,11 @@ import java.util.stream.Stream;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException; import jakarta.persistence.NoResultException;
import jakarta.persistence.TypedQuery; 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.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
@ -43,6 +50,8 @@ import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider; 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.OrganizationDomainEntity;
import org.keycloak.models.jpa.entities.OrganizationEntity; import org.keycloak.models.jpa.entities.OrganizationEntity;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
@ -175,6 +184,32 @@ public class JpaOrganizationProvider implements OrganizationProvider {
.map(entity -> new OrganizationAdapter(realm, entity, this))); .map(entity -> new OrganizationAdapter(realm, entity, this)));
} }
@Override
public Stream<OrganizationModel> getAllStream(Map<String, String> attributes, Integer first, Integer max) {
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery query = builder.createQuery(OrganizationEntity.class);
Root<OrganizationEntity> org = query.from(OrganizationEntity.class);
Root<GroupEntity> group = query.from(GroupEntity.class);
List<Predicate> 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<String, String> entry : attributes.entrySet()) {
if (StringUtil.isNotBlank(entry.getKey())) {
Join<GroupEntity, GroupAttributeEntity> 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<OrganizationEntity> typedQuery = em.createQuery(query.select(org).where(finalPredicate));
return closing(paginateQuery(typedQuery, first, max).getResultStream())
.map(entity -> new OrganizationAdapter(realm, entity, this));
}
@Override @Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) { public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
throwExceptionIfObjectIsNull(organization, "Organization"); throwExceptionIfObjectIsNull(organization, "Organization");

View file

@ -17,6 +17,7 @@
package org.keycloak.organization; package org.keycloak.organization;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.keycloak.models.IdentityProviderModel; 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 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. * @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<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max); Stream<OrganizationModel> 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<OrganizationModel> getAllStream(Map<String, String> attributes, 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

@ -19,6 +19,7 @@ package org.keycloak.organization.admin.resource;
import static java.util.Optional.ofNullable; import static java.util.Optional.ofNullable;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -39,6 +40,8 @@ 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.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; 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.KeycloakSession;
import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationDomainModel;
import org.keycloak.models.OrganizationModel; 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.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.AdminEventBuilder; import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.utils.SearchQueryUtils;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
@Provider @Provider
@ -86,17 +91,39 @@ public class OrganizationResource {
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build(); 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 @GET
@Produces(MediaType.APPLICATION_JSON) @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") @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 either an organization name or domain") @QueryParam("search") String 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 = "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 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 @Parameter(description = "The maximum number of results to be returned - defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max
) { ) {
auth.realm().requireManageRealm(); 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<String, String> 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}") @Path("{id}")

View file

@ -55,6 +55,7 @@ public class KeycloakOpenAPI {
public static final String ROOT = "Root"; public static final String ROOT = "Root";
public static final String SCOPE_MAPPINGS = "Scope Mappings"; public static final String SCOPE_MAPPINGS = "Scope Mappings";
public static final String USERS = "Users"; public static final String USERS = "Users";
public static final String ORGANIZATIONS = "Organizations";
private Tags() { } private Tags() { }
} }

View file

@ -34,6 +34,7 @@ import static org.junit.Assert.fail;
import static org.keycloak.testsuite.admin.group.GroupSearchTest.buildSearchQuery; import static org.keycloak.testsuite.admin.group.GroupSearchTest.buildSearchQuery;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; 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")); assertThat(orgNames, containsInAnyOrder("ztest-6", "ztest-7", "ztest-8", "ztest-9"));
} }
@Test
public void testSearchByAttributes() {
List<OrganizationRepresentation> 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<OrganizationRepresentation> 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 @Test
public void testDelete() { public void testDelete() {
OrganizationRepresentation expected = createOrganization(); OrganizationRepresentation expected = createOrganization();
@ -227,6 +294,7 @@ public class OrganizationTest extends AbstractOrganizationTest {
updated = organization.toRepresentation(); updated = organization.toRepresentation();
assertEquals(0, updated.getAttributes().size()); assertEquals(0, updated.getAttributes().size());
} }
@Test @Test