Add ability to search organizations by attribute
Closes #29411 Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
parent
6864ee0ead
commit
ceed7bc120
6 changed files with 178 additions and 8 deletions
|
@ -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<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 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<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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<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
|
||||
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
|
||||
throwExceptionIfObjectIsNull(organization, "Organization");
|
||||
|
|
|
@ -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<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.
|
||||
*
|
||||
|
|
|
@ -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<OrganizationRepresentation> 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<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}")
|
||||
|
|
|
@ -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() { }
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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
|
||||
public void testDelete() {
|
||||
OrganizationRepresentation expected = createOrganization();
|
||||
|
@ -227,6 +294,7 @@ public class OrganizationTest extends AbstractOrganizationTest {
|
|||
|
||||
updated = organization.toRepresentation();
|
||||
assertEquals(0, updated.getAttributes().size());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
Loading…
Reference in a new issue