diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupResource.java index 2ad92a0f40e..2eeab380cd4 100755 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupResource.java @@ -86,11 +86,13 @@ public interface GroupResource { void remove(); /** - * Get the paginated list of subgroups belonging to this group + * Get the paginated list of subgroups belonging to this group. * - * @param first - * @param max - * @param briefRepresentation + * @param first the position of the first result to be returned. + * @param max the maximum number of results that are to be returned. + * @param briefRepresentation if {@code true}, each returned subgroup representation will only contain basic information + * (id, name, path, and parentId). If {@code false}, the complete representations of the subgroups + * are returned (include role mappings and attributes). */ @GET @Path("children") @@ -98,6 +100,31 @@ public interface GroupResource { @Consumes(MediaType.APPLICATION_JSON) List getSubGroups(@QueryParam("first") Integer first, @QueryParam("max") Integer max, @QueryParam("briefRepresentation") Boolean briefRepresentation); + /** + * Get the paginated list of subgroups belonging to this group, filtered according to the specified parameters. + * + * @param search a {@code String} representing either an exact group name or a partial name. If empty or {@code null} + * then all subgroups of this group are returned. + * @param exact if {@code true}, the subgroups will be searched using exact match for the {@code search} param. If false + * or {@code null}, the method returns all subgroups that partially match the specified name. + * @param first the position of the first result to be returned. + * @param max the maximum number of results that are to be returned. + * @param briefRepresentation if {@code true}, each returned subgroup representation will only contain basic information + * (id, name, path, and parentId). If {@code false}, the complete representations of the subgroups + * are returned (including role mappings and attributes). + */ + @GET + @Path("children") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + List getSubGroups( + @QueryParam("search") String search, + @QueryParam("exact") Boolean exact, + @QueryParam("first") Integer first, + @QueryParam("max") Integer max, + @QueryParam("briefRepresentation") Boolean briefRepresentation); + + /** * Set or create child. This will just set the parent if it exists. Create it and set the parent * if the group doesn't exist. diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java index cf48d37c598..511dbb9ebc9 100755 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java @@ -41,8 +41,8 @@ public interface GroupsResource { /** * Get groups by pagination params. - * @param first index of the first element - * @param max max number of occurrences + * @param first index of the first element (pagination offset). + * @param max the maximum number of results. * @return A list containing the slice of all groups. */ @GET @@ -52,9 +52,9 @@ public interface GroupsResource { /** * Get groups by pagination params. - * @param search max number of occurrences - * @param first index of the first element - * @param max max number of occurrences + * @param search A {@code String} representing either an exact or partial group name. + * @param first index of the first element (pagination offset). + * @param max the maximum number of results. * @return A list containing the slice of all groups. */ @GET @@ -66,10 +66,12 @@ public interface GroupsResource { /** * Get groups by pagination params. - * @param search max number of occurrences - * @param first index of the first element - * @param max max number of occurrences - * @param briefRepresentation if false, return groups with their attributes + * @param search A {@code String} representing either an exact or partial group name. + * @param first index of the first element (pagination offset). + * @param max the maximum number of results. + * @param briefRepresentation if {@code true}, each returned group representation will only contain basic information + * (id, name, path, and parentId). If {@code false}, the complete representations of the groups + * are returned (including role mappings and attributes). * @return A list containing the slice of all groups. */ @GET @@ -82,11 +84,14 @@ public interface GroupsResource { /** * Get groups by pagination params. - * @param search search string for group - * @param exact exact match for search - * @param first index of the first element - * @param max max number of occurrences - * @param briefRepresentation if false, return groups with their attributes + * @param search A {@code String} representing either an exact or partial group name. + * @param exact if {@code true}, the groups will be searched using exact match for the {@code search} param. If false, + * * the method returns all groups that partially match the specified name. + * @param first index of the first element (pagination offset). + * @param max the maximum number of results. + * @param briefRepresentation if {@code true}, each returned group representation will only contain basic information + * (id, name, path, and parentId). If {@code false}, the complete representations of the groups + * are returned (including role mappings and attributes). * @return A list containing the slice of all groups. */ @GET diff --git a/js/apps/admin-ui/src/groups/GroupTable.tsx b/js/apps/admin-ui/src/groups/GroupTable.tsx index 5beb69324d5..148bc76f308 100644 --- a/js/apps/admin-ui/src/groups/GroupTable.tsx +++ b/js/apps/admin-ui/src/groups/GroupTable.tsx @@ -50,6 +50,7 @@ export const GroupTable = ({ refresh: viewRefresh }: GroupTableProps) => { let groupsData = undefined; if (id) { const args: SubGroupQuery = { + search: search || "", first: first, max: max, parentId: id, diff --git a/js/libs/keycloak-admin-client/src/resources/groups.ts b/js/libs/keycloak-admin-client/src/resources/groups.ts index 5139c2ef1bc..804107f9b80 100644 --- a/js/libs/keycloak-admin-client/src/resources/groups.ts +++ b/js/libs/keycloak-admin-client/src/resources/groups.ts @@ -23,7 +23,8 @@ interface SummarizedQuery { } export type GroupQuery = Query & PaginatedQuery & SummarizedQuery; -export type SubGroupQuery = PaginatedQuery & +export type SubGroupQuery = Query & + PaginatedQuery & SummarizedQuery & { parentId: string; }; @@ -142,7 +143,7 @@ export class Groups extends Resource<{ realm?: string }> { method: "GET", path: "/{parentId}/children", urlParamKeys: ["parentId"], - queryParamKeys: ["first", "max", "briefRepresentation"], + queryParamKeys: ["search", "first", "max", "briefRepresentation"], catchNotFound: true, }, ); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java b/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java index ecee44b292b..097e7f0f876 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java @@ -159,15 +159,18 @@ public class GroupResource { @Produces(MediaType.APPLICATION_JSON) @Tag(name = KeycloakOpenAPI.Admin.Tags.GROUPS) @Operation( summary = "Return a paginated list of subgroups that have a parent group corresponding to the group on the URL") - public Stream getSubGroups(@QueryParam("first") @DefaultValue("0") Integer first, - @QueryParam("max") @DefaultValue("10") Integer max, - @QueryParam("briefRepresentation") @DefaultValue("false") Boolean briefRepresentation) { + public Stream getSubGroups( + @Parameter(description = "A String representing either an exact group name or a partial name") @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, + @Parameter(description = "Boolean which defines whether brief groups representations are returned or not (default: false)") @QueryParam("briefRepresentation") @DefaultValue("false") Boolean briefRepresentation) { this.auth.groups().requireView(group); boolean canViewGlobal = auth.groups().canView(); - return paginatedStream(group.getSubGroupsStream(-1, -1) - .filter(g -> canViewGlobal || auth.groups().canView(g)) - .map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, !briefRepresentation))) - , first, max); + return paginatedStream( + group.getSubGroupsStream(search, exact, -1, -1) + .filter(g -> canViewGlobal || auth.groups().canView(g)), first, max) + .map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, !briefRepresentation))); } /** diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java index de39a6a78b2..194be84a142 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java @@ -1,7 +1,10 @@ package org.keycloak.testsuite.admin.group; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anEmptyMap; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; @@ -11,6 +14,7 @@ import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.jboss.arquillian.container.test.api.ContainerController; @@ -214,6 +218,61 @@ public class GroupSearchTest extends AbstractGroupTest { } } + @Test + public void querySubGroups() { + // create a parent group with a few subgroups. + try (Creator parentGroupCreator = Creator.create(testRealmResource(), parentGroup)) { + for (int i = 1; i <= 5; i++) { + GroupRepresentation testGroup = new GroupRepresentation(); + testGroup.setName("kcgroup-" + i); + testGroup.setAttributes(new HashMap<>() {{ + put(ATTR_ORG_NAME, Collections.singletonList("keycloak org")); + put(ATTR_QUOTES_NAME, Collections.singletonList(ATTR_QUOTES_VAL)); + }}); + parentGroupCreator.resource().subGroup(testGroup); + } + for (int i = 1; i <= 3; i++) { + GroupRepresentation testGroup = new GroupRepresentation(); + testGroup.setName("testgroup-" + i); + parentGroupCreator.resource().subGroup(testGroup); + } + + // search for subgroups filtering by name - all groups with 'kc' in the name. + List subGroups = parentGroupCreator.resource().getSubGroups("kc", false, 0, 10, true); + assertThat(subGroups, hasSize(5)); + for (int i = 1; i <= 5; i++) { + // subgroups should be ordered by name. + assertThat(subGroups.get(i-1).getName(), is(equalTo("kcgroup-" + i))); + assertThat(subGroups.get(i-1).getAttributes(), is(nullValue())); // brief rep - no attributes should be returned in subgroups. + } + + // search for subgroups filtering by name - all groups with 'test' in the name. + subGroups = parentGroupCreator.resource().getSubGroups("test", false, 0, 10, true); + assertThat(subGroups, hasSize(3)); + for (int i = 1; i <= 3; i++) { + assertThat(subGroups.get(i-1).getName(), is(equalTo("testgroup-" + i))); + } + + // search for subgroups filtering by name - all groups with 'gro' in the name. + subGroups = parentGroupCreator.resource().getSubGroups("gro", false, 0, 10, true); + assertThat(subGroups, hasSize(8)); + + // search using a string that matches none of the subgroups. + subGroups = parentGroupCreator.resource().getSubGroups("nonexistent", false, 0, 10, false); + assertThat(subGroups, is(empty())); + + // exact search with full representation - only one subgroup should be returned. + subGroups = parentGroupCreator.resource().getSubGroups("kcgroup-2", true, 0, 10, false); + assertThat(subGroups, hasSize(1)); + assertThat(subGroups.get(0).getName(), is(equalTo("kcgroup-2"))); + // attributes should be present in the returned subgroup. + Map> attributes = subGroups.get(0).getAttributes(); + assertThat(attributes, not(anEmptyMap())); + assertThat(attributes.keySet(), hasSize(2)); + assertThat(attributes.keySet(), containsInAnyOrder(ATTR_ORG_NAME, ATTR_QUOTES_NAME)); + } + } + private void search(String searchQuery, String... expectedGroupIds) { GroupsResource search = testRealmResource().groups(); List found = search.query(searchQuery).stream()