Group scalability upgrades (#22700)

closes #22372 


Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
Co-authored-by: Michal Hajas <mhajas@redhat.com>
This commit is contained in:
Alice 2023-10-26 10:50:45 -04:00 committed by GitHub
parent 54a081832a
commit 69497382d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 736 additions and 554 deletions

View file

@ -164,7 +164,7 @@ public class GroupPolicyProviderFactory implements PolicyProviderFactory<GroupPo
config.put("groupsClaim", groupsClaim);
}
List<GroupModel> topLevelGroups = authorization.getRealm().getTopLevelGroupsStream().collect(Collectors.toList());
List<GroupModel> topLevelGroups = authorization.getKeycloakSession().groups().getTopLevelGroupsStream(authorization.getRealm()).collect(Collectors.toList());
for (GroupPolicyRepresentation.GroupDefinition definition : groups) {
GroupModel group = null;

View file

@ -17,23 +17,35 @@
package org.keycloak.representations.idm;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class GroupRepresentation {
// For an individual group these are the sufficient minimum fields
// to identify a group and operate on it in a basic way
protected String id;
protected String name;
protected String path;
protected String parentId;
protected Long subGroupCount;
// For navigating a hierarchy of groups, we can also include a minimum representation of subGroups
// These aren't populated by default and are only included as-needed
protected List<GroupRepresentation> subGroups;
protected Map<String, List<String>> attributes;
protected List<String> realmRoles;
protected Map<String, List<String>> clientRoles;
protected List<GroupRepresentation> subGroups;
private Map<String, Boolean> access;
public String getId() {
@ -60,6 +72,22 @@ public class GroupRepresentation {
this.path = path;
}
public String getParentId() {
return parentId;
}
public void setParentId(String parentId) {
this.parentId = parentId;
}
public Long getSubGroupCount() {
return subGroupCount;
}
public void setSubGroupCount(Long subGroupCount) {
this.subGroupCount = subGroupCount;
}
public List<String> getRealmRoles() {
return realmRoles;
}
@ -92,6 +120,9 @@ public class GroupRepresentation {
}
public List<GroupRepresentation> getSubGroups() {
if(subGroups == null) {
subGroups = new ArrayList<>();
}
return subGroups;
}
@ -106,4 +137,49 @@ public class GroupRepresentation {
public void setAccess(Map<String, Boolean> access) {
this.access = access;
}
public void merge(GroupRepresentation g) {
merge(this, g);
}
private void merge(GroupRepresentation g1, GroupRepresentation g2) {
if(g1.equals(g2)) {
Map<String, GroupRepresentation> g1Children = g1.getSubGroups().stream().collect(Collectors.toMap(GroupRepresentation::getId, g -> g));
Map<String, GroupRepresentation> g2Children = g2.getSubGroups().stream().collect(Collectors.toMap(GroupRepresentation::getId, g -> g));
g2Children.forEach((key, value) -> {
if (g1Children.containsKey(key)) {
merge(g1Children.get(key), value);
} else {
g1Children.put(key, value);
}
});
g1.setSubGroups(new ArrayList<>(g1Children.values()));
}
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
GroupRepresentation that = (GroupRepresentation) o;
boolean isEqual = Objects.equals(id, that.id) && Objects.equals(parentId, that.parentId);
if(isEqual) {
return true;
} else {
return Objects.equals(name, that.name) && Objects.equals(path, that.path);
}
}
@Override
public int hashCode() {
if(id == null) {
return Objects.hash(name, path);
}
return Objects.hash(id, parentId);
}
}

View file

@ -107,3 +107,31 @@ bin/kc.sh start --db postgres --db-username keycloak --db-url "jdbc:postgresql:/
The form action `RegistrationProfile` (displayed in the UI of authentication flows as `Profile Validation`) was removed from the codebase and also from all authentication flows. By default, it was in
the built-in registration flow of every realm. The validation of user attributes as well as creation of the user including all that user's attributes is handled by `RegistrationUserCreation` form action and
hence `RegistrationProfile` is not needed anymore. There is usually no further action needed in relation to this change, unless you used `RegistrationProfile` class in your own providers.
= Deprecated methods from data providers and models
* `RealmModel#getTopLevelGroupsStream()` and overloaded methods are now deprecated
= `GroupProvider` changes
A new method has been added to allow for searching and paging through top level groups.
If you implement this interface you will need to implement the following method:
[source,java]
----
Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm,
String search,
Boolean exact,
Integer firstResult,
Integer maxResults)
----
= `GroupRepresentation` changes
* new field `subGroupCount` added to inform client how many subgroups are on any given group
* `subGroups` list is now only populated on queries that request hierarchy data
* This field is populated from the "bottom up" so can't be relied on for getting all subgroups for a group. Use a `GroupProvider` or request the subgroups from `GET {keycloak server}/realms/{realm}/groups/{group_id}/children`
= New endpoint for Group Admin API
Endpoint `GET {keycloak server}/realms/{realm}/groups/{group_id}/children` added as a way to get subgroups of specific groups that support pagination

View file

@ -814,7 +814,8 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
if (parentGroup == null) {
parentGroup = getKcGroupsPathGroup(realm);
}
return parentGroup == null ? realm.getTopLevelGroupsStream() : parentGroup.getSubGroupsStream();
return parentGroup == null ? session.groups().getTopLevelGroupsStream(realm) :
parentGroup.getSubGroupsStream();
}
/**

View file

@ -85,6 +85,18 @@ public interface GroupResource {
@DELETE
void remove();
/**
* Get the paginated list of subgroups belonging to this group
*
* @param first
* @param max
* @param full
*/
@GET
@Path("children")
@Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
@Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
List<GroupRepresentation> getSubGroups(@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

View file

@ -3,15 +3,15 @@ import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import SessionsPage from "../support/pages/admin-ui/manage/sessions/SessionsPage";
import CommonPage from "../support/pages/CommonPage";
import ListingPage from "../support/pages/admin-ui/ListingPage";
import GroupPage from "../support/pages/admin-ui/manage/groups/GroupPage";
import { keycloakBefore } from "../support/util/keycloak_hooks";
import PageObject from "../support/pages/admin-ui/components/PageObject";
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();
const sessionsPage = new SessionsPage();
const commonPage = new CommonPage();
const listingPage = new ListingPage();
const groupPage = new GroupPage();
const page = new PageObject();
describe("Sessions test", () => {
const admin = "admin";
@ -42,12 +42,12 @@ describe("Sessions test", () => {
it("search existing session", () => {
listingPage.searchItem(admin, false);
listingPage.itemExist(admin, true);
groupPage.assertNoSearchResultsMessageExist(false);
page.assertEmptyStateExist(false);
});
it("search non-existant session", () => {
listingPage.searchItem("non-existant-session", false);
groupPage.assertNoSearchResultsMessageExist(true);
page.assertEmptyStateExist(true);
});
});

View file

@ -329,7 +329,7 @@ export default class PageObject {
return this;
}
protected assertEmptyStateExist(exist: boolean) {
assertEmptyStateExist(exist: boolean) {
if (exist) {
cy.get(this.#emptyStateDiv).should("exist").should("be.visible");
} else {

View file

@ -17,7 +17,7 @@ export default class GroupPage extends PageObject {
protected actionDrpDwnButton = "action-dropdown";
#searchField = "[data-testid='group-search']";
public openCreateGroupModal(emptyState: boolean) {
openCreateGroupModal(emptyState: boolean) {
if (emptyState) {
cy.findByTestId(this.createGroupEmptyStateBtn).click();
} else {
@ -26,7 +26,7 @@ export default class GroupPage extends PageObject {
return this;
}
public createGroup(groupName: string, emptyState: boolean) {
createGroup(groupName: string, emptyState: boolean) {
this.openCreateGroupModal(emptyState);
groupModal
.assertCreateGroupModalVisible(true)
@ -42,12 +42,23 @@ export default class GroupPage extends PageObject {
return this;
}
protected search(searchField: string, searchValue: string, wait: boolean) {
protected search(
searchField: string,
searchValue: string,
wait: boolean,
exact = true,
) {
if (wait) {
const searchUrl = `/admin/realms/master/**/*${searchValue}*`;
cy.intercept(searchUrl).as("search");
}
if (exact) {
cy.findByTestId("exact-search").check();
} else {
cy.findByTestId("exact-search").uncheck();
}
cy.get(searchField + " input").clear();
if (searchValue) {
cy.get(searchField + " input").type(searchValue);
@ -62,26 +73,26 @@ export default class GroupPage extends PageObject {
}
}
public goToGroupChildGroupsTab(groupName: string) {
goToGroupChildGroupsTab(groupName: string) {
listingPage.goToItemDetails(groupName);
cy.intercept("GET", "*/admin/realms/master/groups/*").as("get");
sidebarPage.waitForPageLoad();
return this;
}
public selectGroupItemCheckbox(items: string[]) {
selectGroupItemCheckbox(items: string[]) {
for (const item of items) {
listingPage.clickItemCheckbox(item);
}
return this;
}
public selectGroupItemCheckboxAllRows() {
selectGroupItemCheckboxAllRows() {
listingPage.clickTableHeaderItemCheckboxAllRows();
return this;
}
public deleteSelectedGroups(confirmModal = true) {
deleteSelectedGroups(confirmModal = true) {
this.clickToolbarAction("Delete");
if (confirmModal) {
groupModal.confirmModal();
@ -89,12 +100,12 @@ export default class GroupPage extends PageObject {
return this;
}
public showDeleteSelectedGroupsDialog() {
showDeleteSelectedGroupsDialog() {
this.clickToolbarAction("Delete");
return this;
}
public deleteGroupItem(groupName: string, confirmModal = true) {
deleteGroupItem(groupName: string, confirmModal = true) {
listingPage.deleteItem(groupName);
if (confirmModal) {
groupModal.confirmModal();
@ -102,10 +113,7 @@ export default class GroupPage extends PageObject {
return this;
}
public moveGroupItemAction(
groupName: string,
destinationGroupName: string[],
) {
moveGroupItemAction(groupName: string, destinationGroupName: string[]) {
listingPage.clickRowDetails(groupName);
listingPage.clickDetailMenu("Move to");
moveGroupModal
@ -124,66 +132,68 @@ export default class GroupPage extends PageObject {
return this;
}
public clickBreadcrumbItem(groupName: string) {
clickBreadcrumbItem(groupName: string) {
super.clickBreadcrumbItem(groupName);
return this;
}
public assertGroupItemExist(groupName: string, exist: boolean) {
assertGroupItemExist(groupName: string, exist: boolean) {
listingPage.itemExist(groupName, exist);
return this;
}
public assertNoGroupsInThisRealmEmptyStateMessageExist(exist: boolean) {
assertNoGroupsInThisRealmEmptyStateMessageExist(exist: boolean) {
this.assertEmptyStateExist(exist);
return this;
}
public assertGroupItemsEqual(number: number) {
assertGroupItemsEqual(number: number) {
listingPage.itemsEqualTo(number);
return this;
}
public assertNoSearchResultsMessageExist(exist: boolean) {
super.assertEmptyStateExist(exist);
assertNoSearchResultsMessageExist(exist: boolean) {
if (!exist) {
cy.get("keycloak_groups_treeview").should("be.visible");
} else {
cy.get("keycloak_groups_treeview").should("not.exist");
}
return this;
}
public assertNotificationGroupDeleted() {
assertNotificationGroupDeleted() {
masthead.checkNotificationMessage("Group deleted");
return this;
}
public assertNotificationGroupsDeleted() {
assertNotificationGroupsDeleted() {
masthead.checkNotificationMessage("Groups deleted");
return this;
}
public assertNotificationGroupCreated() {
assertNotificationGroupCreated() {
masthead.checkNotificationMessage("Group created");
return this;
}
public assertNotificationGroupMoved() {
assertNotificationGroupMoved() {
masthead.checkNotificationMessage("Group moved");
return this;
}
public assertNotificationGroupUpdated() {
assertNotificationGroupUpdated() {
masthead.checkNotificationMessage("Group updated");
return this;
}
public assertNotificationCouldNotCreateGroupWithEmptyName() {
assertNotificationCouldNotCreateGroupWithEmptyName() {
masthead.checkNotificationMessage(
"Could not create group Group name is missing",
);
return this;
}
public assertNotificationCouldNotCreateGroupWithDuplicatedName(
groupName: string,
) {
assertNotificationCouldNotCreateGroupWithDuplicatedName(groupName: string) {
masthead.checkNotificationMessage(
"Could not create group Top level group named '" +
groupName +
@ -192,7 +202,7 @@ export default class GroupPage extends PageObject {
return this;
}
public goToGroupActions(groupName: string) {
goToGroupActions(groupName: string) {
listingPage.clickRowDetails(groupName);
return this;

View file

@ -1,4 +1,8 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import {
GroupQuery,
SubGroupQuery,
} from "@keycloak/keycloak-admin-client/lib/resources/groups";
import {
Breadcrumb,
BreadcrumbItem,
@ -17,7 +21,6 @@ import { AngleRightIcon } from "@patternfly/react-icons";
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminClient } from "../../admin-client";
import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint";
import { useFetch } from "../../utils/useFetch";
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
import { PaginatingTableToolbar } from "../table-toolbar/PaginatingTableToolbar";
@ -73,24 +76,31 @@ export const GroupPickerDialog = ({
let group;
let groups;
let existingUserGroups;
if (!groupId) {
groups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups",
Object.assign(
{
first: `${first}`,
max: `${max + 1}`,
global: "false",
},
isSearching ? { search: filter, global: "true" } : null,
),
);
} else if (!navigation.map(({ id }) => id).includes(groupId)) {
group = await adminClient.groups.findOne({ id: groupId });
if (!group) {
throw new Error(t("notFound"));
const args: GroupQuery = {
first: first,
max: max + 1,
};
if (isSearching) {
args.search = filter;
}
groups = await adminClient.groups.find(args);
} else {
if (!navigation.map(({ id }) => id).includes(groupId)) {
group = await adminClient.groups.findOne({ id: groupId });
if (!group) {
throw new Error(t("common:notFound"));
}
}
if (group?.id) {
const args: SubGroupQuery = {
first: first,
max: max + 1,
parentId: group.id,
};
groups = await adminClient.groups.listSubGroups(args);
}
groups = group.subGroups!;
}
if (id) {

View file

@ -1,4 +1,8 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import {
GroupQuery,
SubGroupQuery,
} from "@keycloak/keycloak-admin-client/lib/resources/groups";
import { SearchInput, ToolbarItem } from "@patternfly/react-core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -7,7 +11,6 @@ import { Link, useLocation } from "react-router-dom";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAccess } from "../context/access/Access";
import { fetchAdminUI } from "../context/auth/admin-ui-endpoint";
import useToggle from "../utils/useToggle";
import { GroupsModal } from "./GroupsModal";
import { useSubGroups } from "./SubGroupsContext";
@ -15,6 +18,7 @@ import { DeleteGroup } from "./components/DeleteGroup";
import { GroupToolbar } from "./components/GroupToolbar";
import { MoveDialog } from "./components/MoveDialog";
import { getLastId } from "./groupIdUtils";
import { adminClient } from "../admin-client";
type GroupTableProps = {
refresh: () => void;
@ -47,23 +51,21 @@ export const GroupTable = ({
const isManager = hasAccess("manage-users") || currentGroup()?.access?.manage;
const loader = async (first?: number, max?: number) => {
const params: Record<string, string> = {
search: search || "",
first: first?.toString() || "",
max: max?.toString() || "",
};
let groupsData = undefined;
if (id) {
groupsData = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups/subgroup",
{ ...params, id },
);
const args: SubGroupQuery = {
first: first,
max: max,
parentId: id,
};
groupsData = await adminClient.groups.listSubGroups(args);
} else {
groupsData = await fetchAdminUI<GroupRepresentation[]>("ui-ext/groups", {
...params,
global: "false",
});
const args: GroupQuery = {
search: search || "",
first: first || undefined,
max: max || undefined,
};
groupsData = await adminClient.groups.find(args);
}
return groupsData;

View file

@ -23,7 +23,7 @@ import { GroupBreadCrumbs } from "../components/bread-crumb/GroupBreadCrumbs";
import { PermissionsTab } from "../components/permission-tab/PermissionTab";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAccess } from "../context/access/Access";
import { fetchAdminUI } from "../context/auth/admin-ui-endpoint";
import { adminClient } from "../admin-client";
import { useRealm } from "../context/realm-context/RealmContext";
import helpUrls from "../help-urls";
import { useFetch } from "../utils/useFetch";
@ -84,12 +84,12 @@ export default function GroupsSection() {
if (isNavigationStateInValid) {
const groups: GroupRepresentation[] = [];
for (const i of ids!) {
const group =
i !== "search"
? await fetchAdminUI<GroupRepresentation | undefined>(
"ui-ext/groups/" + i,
)
: { name: t("searchGroups"), id: "search" };
let group = undefined;
if (i !== "search") {
group = await adminClient.groups.findOne({ id: i });
} else {
group = { name: t("searchGroups"), id: "search" };
}
if (group) {
groups.push(group);
} else {

View file

@ -1,5 +1,6 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { SubGroupQuery } from "@keycloak/keycloak-admin-client/lib/resources/groups";
import {
AlertVariant,
Button,
@ -41,7 +42,7 @@ const MemberOfRenderer = (member: MembersOf) => {
<>
{member.membership.map((group, index) => (
<>
<GroupPath key={group.id} group={group} />
<GroupPath key={group.id + "-" + member.id} group={group} />
{member.membership[index + 1] ? ", " : ""}
</>
))}
@ -87,30 +88,51 @@ export const Members = () => {
const getMembership = async (id: string) =>
await adminClient.users.listGroups({ id: id! });
const getSubGroups = (groups: GroupRepresentation[]) => {
let subGroups: GroupRepresentation[] = [];
for (const group of groups!) {
subGroups.push(group);
const subs = getSubGroups(group.subGroups!);
subGroups = subGroups.concat(subs);
// this queries the subgroups using the new search paradigm but doesn't
// account for pagination and therefore isn't going to scale well
const getSubGroups = async (groupId?: string, count = 0) => {
let nestedGroups: GroupRepresentation[] = [];
if (!count || !groupId) {
return nestedGroups;
}
return subGroups;
const args: SubGroupQuery = {
parentId: groupId,
first: 0,
max: count,
};
const subGroups: GroupRepresentation[] =
await adminClient.groups.listSubGroups(args);
nestedGroups = nestedGroups.concat(subGroups);
await Promise.all(
subGroups.map((g) => getSubGroups(g.id, g.subGroupCount)),
).then((values: GroupRepresentation[][]) => {
values.forEach((groups) => (nestedGroups = nestedGroups.concat(groups)));
});
return nestedGroups;
};
const loader = async (first?: number, max?: number) => {
if (!id) {
return [];
}
let members = await adminClient.groups.listMembers({
id: id!,
first,
max,
});
if (includeSubGroup) {
const subGroups = getSubGroups(currentGroup?.subGroups || []);
for (const group of subGroups) {
members = members.concat(
await adminClient.groups.listMembers({ id: group.id! }),
);
}
if (includeSubGroup && currentGroup?.subGroupCount && currentGroup.id) {
const subGroups = await getSubGroups(
currentGroup.id,
currentGroup.subGroupCount,
);
await Promise.all(
subGroups.map((g) => adminClient.groups.listMembers({ id: g.id! })),
).then((values: UserRepresentation[][]) => {
values.forEach((users) => (members = members.concat(users)));
});
members = uniqBy(members, (member) => member.username);
}

View file

@ -183,7 +183,7 @@ export const GroupTree = ({
useFetch(
async () => {
const groups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups",
"groups",
Object.assign(
{
first: `${first}`,
@ -197,9 +197,8 @@ export const GroupTree = ({
let subGroups: GroupRepresentation[] = [];
if (activeItem) {
subGroups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups/subgroup",
`groups/${activeItem.id}/children`,
{
id: activeItem.id!,
first: `${firstSub}`,
max: `${SUBGROUP_COUNT}`,
},

View file

@ -220,6 +220,7 @@ Demo code: https://github.com/keycloak/keycloak/blob/main/js/libs/keycloak-admin
- Count (`GET /{realm}/groups/count`)
- List members (`GET /{realm}/groups/{id}/members`)
- Set or create child (`POST /{realm}/groups/{id}/children`)
- Get children (`GET /{realm}/groups/{id}/children`)
### Group role-mapping

View file

@ -6,6 +6,7 @@ export default interface GroupRepresentation {
id?: string;
name?: string;
path?: string;
subGroupCount?: number;
subGroups?: GroupRepresentation[];
// optional in response

View file

@ -7,13 +7,26 @@ import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
import type UserRepresentation from "../defs/userRepresentation.js";
import Resource from "./resource.js";
export interface GroupQuery {
interface Query {
search?: string;
exact?: boolean;
}
interface PaginatedQuery {
first?: number;
max?: number;
search?: string;
}
interface SummarizedQuery {
briefRepresentation?: boolean;
}
export type GroupQuery = Query & PaginatedQuery & SummarizedQuery;
export type SubGroupQuery = PaginatedQuery &
SummarizedQuery & {
parentId: string;
};
export interface GroupCountQuery {
search?: string;
top?: boolean;
@ -22,6 +35,7 @@ export interface GroupCountQuery {
export class Groups extends Resource<{ realm?: string }> {
public find = this.makeRequest<GroupQuery, GroupRepresentation[]>({
method: "GET",
queryParamKeys: ["search", "exact", "briefRepresentation", "first", "max"],
});
public create = this.makeRequest<GroupRepresentation, { id: string }>({
@ -112,6 +126,19 @@ export class Groups extends Resource<{ realm?: string }> {
urlParamKeys: ["id"],
});
/**
* Finds all subgroups on the specified parent group matching the provided parameters.
*/
public listSubGroups = this.makeRequest<SubGroupQuery, GroupRepresentation[]>(
{
method: "GET",
path: "/{parentId}/children",
urlParamKeys: ["parentId"],
queryParamKeys: ["first", "max", "briefRepresentation"],
catchNotFound: true,
},
);
/**
* Members
*/

View file

@ -6,6 +6,7 @@ import type ClientRepresentation from "../src/defs/clientRepresentation.js";
import type GroupRepresentation from "../src/defs/groupRepresentation.js";
import type RoleRepresentation from "../src/defs/roleRepresentation.js";
import { credentials } from "./constants.js";
import { SubGroupQuery } from "../src/resources/groups.js";
const expect = chai.expect;
@ -93,11 +94,20 @@ describe("Groups", () => {
const group = (await kcAdminClient.groups.findOne({
id: groupId!,
}))!;
expect(group.subGroups![0]).to.deep.include({
id: childGroup.id,
name: groupName,
path: `/${group.name}/${groupName}`,
});
expect(group).to.be.ok;
});
it("list subgroups", async () => {
if (currentGroup.id) {
const args: SubGroupQuery = {
parentId: currentGroup!.id,
first: 0,
max: 10,
briefRepresentation: false,
};
const groups = await kcAdminClient.groups.listSubGroups(args);
expect(groups.length).to.equal(1);
}
});
/**

View file

@ -237,7 +237,29 @@ public class GroupAdapter implements GroupModel {
return subGroups.stream().sorted(GroupModel.COMPARE_BY_NAME);
}
@Override
public Stream<GroupModel> getSubGroupsStream(String search, Integer firstResult, Integer maxResults) {
if (isUpdated()) return updated.getSubGroupsStream(search, firstResult, maxResults);
return modelSupplier.get().getSubGroupsStream(search, firstResult, maxResults);
}
@Override
public Stream<GroupModel> getSubGroupsStream(Integer firstResult, Integer maxResults) {
if (isUpdated()) return updated.getSubGroupsStream(firstResult, maxResults);
return modelSupplier.get().getSubGroupsStream(firstResult, maxResults);
}
@Override
public Stream<GroupModel> getSubGroupsStream(String search, Boolean exact, Integer firstResult, Integer maxResults) {
if (isUpdated()) return updated.getSubGroupsStream(search, exact, firstResult, maxResults);
return modelSupplier.get().getSubGroupsStream(search, exact, firstResult, maxResults);
}
@Override
public Long getSubGroupsCount() {
if (isUpdated()) return updated.getSubGroupsCount();
return modelSupplier.get().getSubGroupsCount();
}
@Override
public void setParent(GroupModel group) {

View file

@ -989,7 +989,6 @@ public class RealmCacheSession implements CacheRealmProvider {
public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) {
return getGroupDelegate().getGroupsCount(realm, onlyTopGroups);
}
@Override
public long getClientsCount(RealmModel realm) {
return getClientDelegate().getClientsCount(realm);
@ -1006,49 +1005,12 @@ public class RealmCacheSession implements CacheRealmProvider {
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) {
String cacheKey = getTopGroupsQueryCacheKey(realm.getId());
boolean queryDB = invalidations.contains(cacheKey) || listInvalidations.contains(realm.getId());
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer first, Integer max) {
String cacheKey = getTopGroupsQueryCacheKey(realm.getId() + search + first + max);
boolean queryDB = invalidations.contains(cacheKey) || listInvalidations.contains(cacheKey)
|| listInvalidations.contains(realm.getId());
if (queryDB) {
return getGroupDelegate().getTopLevelGroupsStream(realm);
}
GroupListQuery query = cache.get(cacheKey, GroupListQuery.class);
if (query != null) {
logger.tracev("getTopLevelGroups cache hit: {0}", realm.getName());
}
if (query == null) {
Long loaded = cache.getCurrentRevision(cacheKey);
List<GroupModel> model = getGroupDelegate().getTopLevelGroupsStream(realm).collect(Collectors.toList());
if (model.isEmpty()) return Stream.empty();
Set<String> ids = new HashSet<>();
for (GroupModel client : model) ids.add(client.getId());
query = new GroupListQuery(loaded, cacheKey, realm, ids);
logger.tracev("adding realm getTopLevelGroups cache miss: realm {0} key {1}", realm.getName(), cacheKey);
cache.addRevisioned(query, startupRevision);
return model.stream();
}
List<GroupModel> list = new LinkedList<>();
for (String id : query.getGroups()) {
GroupModel group = session.groups().getGroupById(realm, id);
if (group == null) {
invalidations.add(cacheKey);
return getGroupDelegate().getTopLevelGroupsStream(realm);
}
list.add(group);
}
return list.stream().sorted(GroupModel.COMPARE_BY_NAME);
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer first, Integer max) {
String cacheKey = getTopGroupsQueryCacheKey(realm.getId() + first + max);
boolean queryDB = invalidations.contains(cacheKey) || listInvalidations.contains(realm.getId() + first + max)
|| listInvalidations.contains(realm.getId());
if (queryDB) {
return getGroupDelegate().getTopLevelGroupsStream(realm, first, max);
return getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max);
}
GroupListQuery query = cache.get(cacheKey, GroupListQuery.class);
@ -1058,7 +1020,7 @@ public class RealmCacheSession implements CacheRealmProvider {
if (Objects.isNull(query)) {
Long loaded = cache.getCurrentRevision(cacheKey);
List<GroupModel> model = getGroupDelegate().getTopLevelGroupsStream(realm, first, max).collect(Collectors.toList());
List<GroupModel> model = getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max).collect(Collectors.toList());
if (model.isEmpty()) return Stream.empty();
Set<String> ids = new HashSet<>();
for (GroupModel client : model) ids.add(client.getId());

View file

@ -20,6 +20,7 @@ package org.keycloak.models.jpa;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.ClientModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.jpa.entities.GroupAttributeEntity;
@ -38,6 +39,7 @@ import java.util.Objects;
import java.util.stream.Stream;
import jakarta.persistence.LockModeType;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing;
/**
@ -121,10 +123,35 @@ public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> {
@Override
public Stream<GroupModel> getSubGroupsStream() {
TypedQuery<String> query = em.createNamedQuery("getGroupIdsByParent", String.class);
query.setParameter("realm", group.getRealm());
query.setParameter("parent", group.getId());
return closing(query.getResultStream().map(realm::getGroupById).filter(Objects::nonNull));
return getSubGroupsStream("", false, -1, -1);
}
@Override
public Stream<GroupModel> getSubGroupsStream(String search, Boolean exact, Integer firstResult, Integer maxResults) {
TypedQuery<String> query;
if (Boolean.TRUE.equals(exact)) {
query = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
} else {
query = em.createNamedQuery("getGroupIdsByParentAndNameContaining", String.class);
}
query.setParameter("realm", realm.getId())
.setParameter("parent", group.getId())
.setParameter("search", search == null ? "" : search);
return closing(paginateQuery(query, firstResult, maxResults).getResultStream()
.map(realm::getGroupById)
// In concurrent tests, the group might be deleted in another thread, therefore, skip those null values.
.filter(Objects::nonNull)
.sorted(GroupModel.COMPARE_BY_NAME)
);
}
@Override
public Long getSubGroupsCount() {
return em.createNamedQuery("getGroupCountByParent", Long.class)
.setParameter("realm", realm.getId())
.setParameter("parent", group.getId())
.getSingleResult();
}
@Override

View file

@ -21,15 +21,6 @@ import static org.keycloak.common.util.StackUtil.getShortStackTrace;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.TypedQuery;
@ -39,7 +30,15 @@ import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.Session;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
@ -71,8 +70,9 @@ import org.keycloak.models.jpa.entities.GroupEntity;
import org.keycloak.models.jpa.entities.RealmEntity;
import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity;
import org.keycloak.models.jpa.entities.RoleEntity;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -183,7 +183,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
session.clientScopes().removeClientScopes(adapter);
session.roles().removeRoles(adapter);
adapter.getTopLevelGroupsStream().forEach(adapter::removeGroup);
session.groups().getTopLevelGroupsStream(adapter).forEach(adapter::removeGroup);
num = em.createNamedQuery("removeClientInitialAccessByRealm")
.setParameter("realm", realm).executeUpdate();
@ -437,8 +437,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public GroupModel getGroupByName(RealmModel realm, GroupModel parent, String name) {
TypedQuery<String> query = em.createNamedQuery("getGroupIdByNameAndParent", String.class);
query.setParameter("name", name);
TypedQuery<String> query = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
query.setParameter("search", name);
query.setParameter("realm", realm.getId());
query.setParameter("parent", parent != null ? parent.getId() : GroupEntity.TOP_PARENT_ID);
List<String> entities = query.getResultList();
@ -566,7 +566,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) {
if(Objects.equals(onlyTopGroups, Boolean.TRUE)) {
return em.createNamedQuery("getTopLevelGroupCount", Long.class)
return em.createNamedQuery("getGroupCountByParent", Long.class)
.setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID)
.getSingleResult();
@ -603,21 +603,23 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) {
return getTopLevelGroupsStream(realm, null, null);
}
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults) {
TypedQuery<String> groupsQuery;
if(Boolean.TRUE.equals(exact)) {
groupsQuery = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
} else {
groupsQuery = em.createNamedQuery("getGroupIdsByParentAndNameContaining", String.class);
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer first, Integer max) {
TypedQuery<String> groupsQuery = em.createNamedQuery("getTopLevelGroupIds", String.class)
.setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID);
groupsQuery.setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID)
.setParameter("search", search);
return closing(paginateQuery(groupsQuery, first, max).getResultStream()
.map(realm::getGroupById)
// In concurrent tests, the group might be deleted in another thread, therefore, skip those null values.
.filter(Objects::nonNull)
.sorted(GroupModel.COMPARE_BY_NAME)
return closing(paginateQuery(groupsQuery, firstResult, maxResults).getResultStream()
.map(realm::getGroupById)
// In concurrent tests, the group might be deleted in another thread, therefore, skip those null values.
.filter(Objects::nonNull)
.sorted(GroupModel.COMPARE_BY_NAME)
);
}
@ -648,6 +650,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
session.users().preRemove(realm, group);
realm.removeDefaultGroup(group);
group.getSubGroupsStream().forEach(realm::removeGroup);
GroupEntity groupEntity = em.find(GroupEntity.class, group.getId(), LockModeType.PESSIMISTIC_WRITE);
@ -1015,14 +1018,9 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
.setParameter("search", search);
Stream<String> groups = paginateQuery(query, first, max).getResultStream();
return closing(groups.map(id -> {
GroupModel groupById = session.groups().getGroupById(realm, id);
while (Objects.nonNull(groupById.getParentId())) {
groupById = session.groups().getGroupById(realm, groupById.getParentId());
}
return groupById;
}).sorted(GroupModel.COMPARE_BY_NAME).distinct());
return closing(groups.map(id -> session.groups().getGroupById(realm, id)).sorted(GroupModel.COMPARE_BY_NAME).distinct());
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
Map<String, String> filteredAttributes = groupSearchableAttributes == null || groupSearchableAttributes.isEmpty()
@ -1057,7 +1055,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
TypedQuery<GroupEntity> query = em.createQuery(queryBuilder);
return closing(paginateQuery(query, firstResult, maxResults).getResultStream())
.map(g -> session.groups().getGroupById(realm, g.getId()));
.map(g -> new GroupAdapter(realm, em, g));
}
@Override

View file

@ -29,16 +29,16 @@ import java.util.LinkedList;
*/
@NamedQueries({
@NamedQuery(name="getGroupIdsByParent", query="select u.id from GroupEntity u where u.realm = :realm and u.parentId = :parent order by u.name ASC"),
@NamedQuery(name="getGroupIdsByParentAndName", query="select u.id from GroupEntity u where u.realm = :realm and u.parentId = :parent and u.name = :search order by u.name ASC"),
@NamedQuery(name="getGroupIdsByParentAndNameContaining", query="select u.id from GroupEntity u where u.realm = :realm and u.parentId = :parent and lower(u.name) like lower(concat('%',:search,'%')) order by u.name ASC"),
@NamedQuery(name="getGroupIdsByRealm", query="select u.id from GroupEntity u where u.realm = :realm order by u.name ASC"),
@NamedQuery(name="getGroupIdsByNameContaining", query="select u.id from GroupEntity u where u.realm = :realm and u.name like concat('%',:search,'%') order by u.name ASC"),
@NamedQuery(name="getGroupIdsByNameContaining", query="select u.id from GroupEntity u where u.realm = :realm and lower(u.name) like lower(concat('%',:search,'%')) order by u.name ASC"),
@NamedQuery(name="getGroupIdsByNameContainingFromIdList", query="select u.id from GroupEntity u where u.realm = :realm and lower(u.name) like lower(concat('%',:search,'%')) and u.id in :ids order by u.name ASC"),
@NamedQuery(name="getGroupIdsByName", query="select u.id from GroupEntity u where u.realm = :realm and u.name = :search order by u.name ASC"),
@NamedQuery(name="getGroupIdsFromIdList", query="select u.id from GroupEntity u where u.realm = :realm and u.id in :ids order by u.name ASC"),
@NamedQuery(name="getGroupCountByNameContainingFromIdList", query="select count(u) from GroupEntity u where u.realm = :realm and lower(u.name) like lower(concat('%',:search,'%')) and u.id in :ids"),
@NamedQuery(name="getTopLevelGroupIds", query="select u.id from GroupEntity u where u.parentId = :parent and u.realm = :realm order by u.name ASC"),
@NamedQuery(name="getGroupCount", query="select count(u) from GroupEntity u where u.realm = :realm"),
@NamedQuery(name="getTopLevelGroupCount", query="select count(u) from GroupEntity u where u.realm = :realm and u.parentId = :parent"),
@NamedQuery(name="getGroupIdByNameAndParent", query="select u.id from GroupEntity u where u.realm = :realm and u.parentId = :parent and u.name = :name")
@NamedQuery(name="getGroupCountByParent", query="select count(u) from GroupEntity u where u.realm = :realm and u.parentId = :parent")
})
@Entity
@Table(name="KEYCLOAK_GROUP",

View file

@ -105,7 +105,7 @@ public class ExportUtils {
// Groups and Roles
if (options.isGroupsAndRolesIncluded()) {
ModelToRepresentation.exportGroups(realm, rep);
ModelToRepresentation.exportGroups(session, realm, rep);
Map<String, List<RoleRepresentation>> clientRolesReps = new HashMap<>();

View file

@ -16,6 +16,8 @@
*/
package org.keycloak.storage;
import java.util.Map;
import java.util.stream.Stream;
import org.keycloak.models.GroupModel;
import org.keycloak.models.GroupProvider;
import org.keycloak.models.KeycloakSession;
@ -26,8 +28,6 @@ import org.keycloak.storage.group.GroupStorageProvider;
import org.keycloak.storage.group.GroupStorageProviderFactory;
import org.keycloak.storage.group.GroupStorageProviderModel;
import java.util.Map;
import java.util.stream.Stream;
public class GroupStorageManager extends AbstractStorageManager<GroupStorageProvider, GroupStorageProviderModel> implements GroupProvider {
@ -85,6 +85,7 @@ public class GroupStorageManager extends AbstractStorageManager<GroupStorageProv
return Stream.concat(local, ext);
}
/* GROUP PROVIDER METHODS - provided only by local storage (e.g. not supported by storage providers) */
@Override
@ -113,13 +114,8 @@ public class GroupStorageManager extends AbstractStorageManager<GroupStorageProv
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) {
return localStorage().getTopLevelGroupsStream(realm);
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer firstResult, Integer maxResults) {
return localStorage().getTopLevelGroupsStream(realm, firstResult, maxResults);
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults) {
return localStorage().getTopLevelGroupsStream(realm, search, exact, firstResult, maxResults);
}
@Override

View file

@ -17,6 +17,11 @@
package org.keycloak.models.map.group;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
import org.keycloak.models.GroupModel;
import org.keycloak.models.GroupModel.SearchableFields;
@ -28,18 +33,13 @@ import org.keycloak.models.RoleModel;
import org.keycloak.models.map.common.DeepCloner;
import org.keycloak.models.map.common.HasRealmId;
import org.keycloak.models.map.storage.MapStorage;
import org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator;
import org.keycloak.models.map.storage.QueryParameters;
import org.keycloak.models.map.storage.criteria.DefaultModelCriteria;
import org.keycloak.models.utils.KeycloakModelUtils;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import static org.keycloak.common.util.StackUtil.getShortStackTrace;
import static org.keycloak.models.map.common.AbstractMapProviderFactory.MapProviderObjectType.GROUP_AFTER_REMOVE;
@ -135,8 +135,7 @@ public class MapGroupProvider implements GroupProvider {
}
return storeWithRealm(realm).read(queryParameters)
.map(entityToAdapterFunc(realm))
;
.map(entityToAdapterFunc(realm));
}
@Override
@ -168,7 +167,14 @@ public class MapGroupProvider implements GroupProvider {
@Override
public Long getGroupsCountByNameContaining(RealmModel realm, String search) {
return searchForGroupByNameStream(realm, search, false, null, null).count();
LOG.tracef("getGroupsCountByNameContaining(%s, %s, %s)%s", realm, session, search, getShortStackTrace());
DefaultModelCriteria<GroupModel> mcb = criteria();
mcb = mcb.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId())
.compare(SearchableFields.NAME, Operator.ILIKE, "%" + search + "%");
return storeWithRealm(realm).read(withCriteria(mcb).orderBy(SearchableFields.NAME, ASCENDING)).count();
}
@Override
@ -181,21 +187,21 @@ public class MapGroupProvider implements GroupProvider {
}
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) {
LOG.tracef("getTopLevelGroupsStream(%s)%s", realm, getShortStackTrace());
return getGroupsStreamInternal(realm,
(DefaultModelCriteria<GroupModel> mcb) -> mcb.compare(SearchableFields.PARENT_ID, Operator.NOT_EXISTS),
null
);
}
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults) {
LOG.tracef("getTopLevelGroupsStream(%s, %s,%s, %s,%s)%s", realm, search, exact, firstResult, maxResults, getShortStackTrace());
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer firstResult, Integer maxResults) {
LOG.tracef("getTopLevelGroupsStream(%s, %s, %s)%s", realm, firstResult, maxResults, getShortStackTrace());
return getGroupsStreamInternal(realm,
(DefaultModelCriteria<GroupModel> mcb) -> mcb.compare(SearchableFields.PARENT_ID, Operator.NOT_EXISTS),
qp -> qp.offset(firstResult).limit(maxResults)
);
DefaultModelCriteria<GroupModel> mcb = criteria();
mcb = mcb.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId())
.compare(SearchableFields.PARENT_ID, Operator.NOT_EXISTS);
if(Boolean.TRUE.equals(exact)) {
mcb.compare(SearchableFields.NAME, Operator.EQ,search);
} else {
mcb.compare(SearchableFields.NAME, Operator.ILIKE, "%" + search + "%");
}
return storeWithRealm(realm).read(withCriteria(mcb).pagination(firstResult, maxResults, SearchableFields.NAME))
.map(entityToAdapterFunc(realm));
}
@Override
@ -214,14 +220,7 @@ public class MapGroupProvider implements GroupProvider {
return storeWithRealm(realm).read(withCriteria(mcb).pagination(firstResult, maxResults, SearchableFields.NAME))
.map(MapGroupEntity::getId)
.map(id -> {
GroupModel groupById = session.groups().getGroupById(realm, id);
while (Objects.nonNull(groupById.getParentId())) {
groupById = session.groups().getGroupById(realm, groupById.getParentId());
}
return groupById;
}).sorted(GroupModel.COMPARE_BY_NAME).distinct();
.map(entityToAdapterFunc(realm));
}
@Override

View file

@ -61,7 +61,14 @@ public class MapGroupProviderFactory extends AbstractMapProviderFactory<MapGroup
GroupModel group = (GroupModel) params[1];
realm.removeDefaultGroup(group);
group.getSubGroupsStream().collect(Collectors.toSet()).forEach(subGroup -> create(session).removeGroup(realm, subGroup));
// TODO: Should the batch size be a config option?
// batch and remove subgroups to avoid grinding server to a halt at scale
long batches = (long) Math.ceil(group.getSubGroupsCount() / 1000.0);
for(int i = 0; i < batches; i++) {
group.getSubGroupsStream(i * 1000, 1000)
.forEach(subGroup -> create(session).removeGroup(realm, subGroup));
}
} else if (type == GROUP_AFTER_REMOVE) {
session.getKeycloakSessionFactory().publish(new GroupModel.GroupRemovedEvent() {
@Override public RealmModel getRealm() { return (RealmModel) params[0]; }

View file

@ -40,11 +40,6 @@ public final class AdminExtResource {
return new EffectiveRoleMappingResource(session, realm, auth);
}
@Path("/groups")
public GroupsResource groups() {
return new GroupsResource(session, realm, auth);
}
@Path("/sessions")
public SessionsResource sessions() {
return new SessionsResource(session, realm, auth);

View file

@ -1,135 +0,0 @@
package org.keycloak.admin.ui.rest;
import java.util.Objects;
import java.util.stream.Stream;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator;
import org.keycloak.utils.GroupUtils;
import static org.keycloak.models.utils.ModelToRepresentation.toRepresentation;
public class GroupsResource {
private final KeycloakSession session;
private final RealmModel realm;
private final AdminPermissionEvaluator auth;
public GroupsResource(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator auth) {
super();
this.realm = realm;
this.auth = auth;
this.session = session;
}
@GET
@Consumes({"application/json"})
@Produces({"application/json"})
@Operation(
summary = "List all groups with fine grained authorisation",
description = "This endpoint returns a list of groups with fine grained authorisation"
)
@APIResponse(
responseCode = "200",
description = "",
content = {@Content(
schema = @Schema(
implementation = GroupRepresentation.class,
type = SchemaType.ARRAY
)
)}
)
public final Stream<GroupRepresentation> listGroups(@QueryParam("search") @DefaultValue("") final String search, @QueryParam("first")
@DefaultValue("0") int first, @QueryParam("max") @DefaultValue("10") int max, @QueryParam("global") @DefaultValue("true") boolean global,
@QueryParam("exact") @DefaultValue("false") boolean exact) {
GroupPermissionEvaluator groupsEvaluator = auth.groups();
groupsEvaluator.requireList();
final Stream<GroupModel> stream;
if (global) {
stream = session.groups().searchForGroupByNameStream(realm, search.trim(), exact, first, max);
} else {
stream = this.realm.getTopLevelGroupsStream().filter(g -> g.getName().contains(search)).skip(first).limit(max);
}
boolean canViewGlobal = groupsEvaluator.canView();
return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group))
.map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact, "".equals(search)));
}
@GET
@Path("/subgroup")
@Consumes({"application/json"})
@Produces({"application/json"})
@Operation(
summary = "List all sub groups with fine grained authorisation and pagination",
description = "This endpoint returns a list of groups with fine grained authorisation"
)
@APIResponse(
responseCode = "200",
description = "",
content = {@Content(
schema = @Schema(
implementation = GroupRepresentation.class,
type = SchemaType.ARRAY
)
)}
)
public final Stream<GroupRepresentation> subgroups(@QueryParam("id") final String groupId, @QueryParam("search")
@DefaultValue("") final String search, @QueryParam("first") @DefaultValue("0") int first, @QueryParam("max") @DefaultValue("10") int max) {
GroupPermissionEvaluator groupsEvaluator = auth.groups();
groupsEvaluator.requireList();
GroupModel group = realm.getGroupById(groupId);
if (group == null) {
return Stream.empty();
}
return group.getSubGroupsStream().filter(g -> g.getName().contains(search))
.map(g -> GroupUtils.toGroupHierarchy(groupsEvaluator, g, search, false, true)).skip(first).limit(max);
}
@GET
@Path("{id}")
@Consumes({"application/json"})
@Produces({"application/json"})
@Operation(
summary = "Find a specific group with no subgroups",
description = "This endpoint returns a group by id with no subgroups"
)
@APIResponse(
responseCode = "200",
description = "",
content = {@Content(
schema = @Schema(
implementation = GroupRepresentation.class,
type = SchemaType.OBJECT
)
)}
)
public GroupRepresentation findGroupById(@PathParam("id") String id) {
GroupModel group = realm.getGroupById(id);
this.auth.groups().requireView(group);
GroupRepresentation rep = toRepresentation(group, true);
rep.setAccess(auth.groups().getAccess(group));
return rep;
}
}

View file

@ -30,6 +30,7 @@ import org.keycloak.authorization.policy.provider.PolicyProviderFactory;
import org.keycloak.authorization.store.PolicyStore;
import org.keycloak.authorization.store.StoreFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
@ -48,7 +49,6 @@ import org.keycloak.representations.idm.*;
import org.keycloak.representations.idm.authorization.*;
import org.keycloak.storage.StorageId;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StreamsUtil;
import org.keycloak.utils.StringUtil;
import java.io.IOException;
@ -131,6 +131,7 @@ public class ModelToRepresentation {
rep.setId(group.getId());
rep.setName(group.getName());
rep.setPath(buildGroupPath(group));
rep.setParentId(group.getParentId());
if (!full) return rep;
// Role mappings
Set<RoleModel> roles = group.getRoleMappingsStream().collect(Collectors.toSet());
@ -153,70 +154,17 @@ public class ModelToRepresentation {
return rep;
}
public static Stream<GroupRepresentation> searchGroupsByAttributes(KeycloakSession session, RealmModel realm, boolean full, boolean populateHierarchy, Map<String,String> attributes, Integer first, Integer max) {
Stream<GroupModel> groups = searchGroupModelsByAttributes(session, realm, full, populateHierarchy, attributes, first, max);
// and then turn the result into GroupRepresentations creating whole hierarchy of child groups for each root group
return groups.map(g -> toGroupHierarchy(g, full, attributes));
public static Stream<GroupModel> searchGroupModelsByAttributes(KeycloakSession session, RealmModel realm, Map<String,String> attributes, Integer first, Integer max) {
return session.groups().searchGroupsByAttributes(realm, attributes, first, max);
}
public static Stream<GroupModel> searchGroupModelsByAttributes(KeycloakSession session, RealmModel realm, boolean full, boolean populateHierarchy, Map<String,String> attributes, Integer first, Integer max) {
Stream<GroupModel> groups = session.groups().searchGroupsByAttributes(realm, attributes, first, max);
if(populateHierarchy) {
groups = groups
// We need to return whole group hierarchy when any child group fulfills the attribute search,
// therefore for each group from the result, we need to find root group
.map(group -> {
while (Objects.nonNull(group.getParentId())) {
group = group.getParent();
}
return group;
})
// More child groups of one root can fulfill the search, so we need to filter duplicates
.filter(StreamsUtil.distinctByKey(GroupModel::getId));
}
return groups;
}
public static Stream<GroupRepresentation> searchForGroupByName(KeycloakSession session, RealmModel realm, boolean full, String search, Boolean exact, Integer first, Integer max) {
return searchForGroupModelByName(session, realm, full, search, exact, first, max)
.map(g -> toGroupHierarchy(g, full, search, exact));
}
public static Stream<GroupModel> searchForGroupModelByName(KeycloakSession session, RealmModel realm, boolean full, String search, Boolean exact, Integer first, Integer max) {
return session.groups().searchForGroupByNameStream(realm, search, exact, first, max);
}
public static Stream<GroupRepresentation> searchForGroupByName(UserModel user, boolean full, String search, Integer first, Integer max) {
return user.getGroupsStream(search, first, max)
.map(group -> toRepresentation(group, full));
}
public static Stream<GroupRepresentation> toGroupHierarchy(RealmModel realm, boolean full, Integer first, Integer max) {
return toGroupModelHierarchy(realm, full, first, max)
.map(g -> toGroupHierarchy(g, full));
}
public static Stream<GroupModel> toGroupModelHierarchy(RealmModel realm, boolean full, Integer first, Integer max) {
return realm.getTopLevelGroupsStream(first, max);
}
public static Stream<GroupRepresentation> toGroupHierarchy(UserModel user, boolean full, Integer first, Integer max) {
return user.getGroupsStream(null, first, max)
.map(group -> toRepresentation(group, full));
}
public static Stream<GroupRepresentation> toGroupHierarchy(RealmModel realm, boolean full) {
return realm.getTopLevelGroupsStream()
@Deprecated
public static Stream<GroupRepresentation> toGroupHierarchy(KeycloakSession session, RealmModel realm, boolean full) {
return session.groups().getTopLevelGroupsStream(realm, null, null)
.map(g -> toGroupHierarchy(g, full));
}
public static Stream<GroupRepresentation> toGroupHierarchy(UserModel user, boolean full) {
return user.getGroupsStream()
.map(group -> toRepresentation(group, full));
}
@Deprecated
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full) {
return toGroupHierarchy(group, full, (String) null);
}
@ -226,6 +174,11 @@ public class ModelToRepresentation {
return toGroupHierarchy(group, full, search, false);
}
@Deprecated
/**
* @deprecated This function is left in place to serve mostly for a full export of all groups.
* There is a GroupUtil class in the keycloak-services module to handle normal search operations
*/
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full, String search, Boolean exact) {
GroupRepresentation rep = toRepresentation(group, full);
List<GroupRepresentation> subGroups = group.getSubGroupsStream()
@ -235,14 +188,6 @@ public class ModelToRepresentation {
return rep;
}
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full, Map<String,String> attributes) {
GroupRepresentation rep = toRepresentation(group, full);
List<GroupRepresentation> subGroups = group.getSubGroupsStream()
.map(subGroup -> toGroupHierarchy(subGroup, full, attributes)).collect(Collectors.toList());
rep.setSubGroups(subGroups);
return rep;
}
private static boolean groupMatchesSearchOrIsPathElement(GroupModel group, String search, Boolean exact) {
if (StringUtil.isBlank(search)) {
return true;
@ -554,7 +499,7 @@ public class ModelToRepresentation {
if (internal) {
exportAuthenticationFlows(session, realm, rep);
exportRequiredActions(realm, rep);
exportGroups(realm, rep);
exportGroups(session, realm, rep);
}
session.clientPolicy().updateRealmRepresentationFromModel(realm, rep);
@ -586,9 +531,8 @@ public class ModelToRepresentation {
return a;
}
public static void exportGroups(RealmModel realm, RealmRepresentation rep) {
rep.setGroups(toGroupHierarchy(realm, true).collect(Collectors.toList()));
public static void exportGroups(KeycloakSession session, RealmModel realm, RealmRepresentation rep) {
rep.setGroups(toGroupHierarchy(session, realm, true).collect(Collectors.toList()));
}
public static void exportAuthenticationFlows(KeycloakSession session, RealmModel realm, RealmRepresentation rep) {

View file

@ -110,6 +110,69 @@ public interface GroupModel extends RoleMapperModel {
*/
Stream<GroupModel> getSubGroupsStream();
/**
* Returns all sub groups for the parent group matching the fuzzy search as a stream, paginated.
* Stream is sorted by the group name.
*
* @param search searched string. If empty or {@code null} all subgroups are returned.
* @return Stream of {@link GroupModel}. Never returns {@code null}.
*/
default Stream<GroupModel> getSubGroupsStream(String search, Integer firstResult, Integer maxResults) {
return getSubGroupsStream(search, false, firstResult, maxResults);
}
/**
* Returns all sub groups for the parent group as a stream, paginated.
*
* @param firstResult First result to return. Ignored if negative or {@code null}.
* @param maxResults Maximum number of results to return. Ignored if negative or {@code null}.
* @return
*/
default Stream<GroupModel> getSubGroupsStream(Integer firstResult, Integer maxResults) {
return getSubGroupsStream(null, firstResult, maxResults);
}
/**
* Returns all subgroups for the parent group matching the search as a stream, paginated.
* Stream is sorted by the group name.
*
* @param search search string. If empty or {@code null} all subgroups are returned.
* @param exact toggles fuzzy searching
* @param firstResult First result to return. Ignored if negative or {@code null}.
* @param maxResults Maximum number of results to return. Ignored if negative or {@code null}.
* @return Stream of {@link GroupModel}. Never returns {@code null}.
*/
default Stream<GroupModel> getSubGroupsStream(String search, Boolean exact, Integer firstResult, Integer maxResults) {
Stream<GroupModel> allSubgorupsGroups = getSubGroupsStream().filter(group -> {
if (search == null || search.isEmpty()) return true;
if (Boolean.TRUE.equals(exact)) {
return group.getName().equals(search);
} else {
return group.getName().toLowerCase().contains(search.toLowerCase());
}
});
// Copied over from StreamsUtil from server-spi-private which is not available here
if (firstResult != null && firstResult > 0) {
allSubgorupsGroups = allSubgorupsGroups.skip(firstResult);
}
if (maxResults != null && maxResults >= 0) {
allSubgorupsGroups = allSubgorupsGroups.limit(maxResults);
}
return allSubgorupsGroups;
}
/**
* Returns the number of groups contained beneath this group.
*
* @return The number of groups beneath this group. Never returns {@code null}.
*/
default Long getSubGroupsCount() {
return getSubGroupsStream().count();
}
/**
* You must also call addChild on the parent group, addChild on RealmModel if there is no parent group
*

View file

@ -125,7 +125,9 @@ public interface GroupProvider extends Provider, GroupLookupProvider {
* @param realm Realm.
* @return Stream of all top level groups in the realm. Never returns {@code null}.
*/
Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm);
default Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) {
return getTopLevelGroupsStream(realm, "", false, null, null);
}
/**
* Returns top level groups (i.e. groups without parent group) for the given realm.
@ -135,7 +137,20 @@ public interface GroupProvider extends Provider, GroupLookupProvider {
* @param maxResults Maximum number of results to return. Ignored if negative or {@code null}.
* @return Stream of top level groups in the realm. Never returns {@code null}.
*/
Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer firstResult, Integer maxResults);
default Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer firstResult, Integer maxResults) {
return getTopLevelGroupsStream(realm, "", false, firstResult, maxResults);
}
/**
* Returns top level groups (i.e. groups without parent group) for the given realm.
*
* @param realm Realm.
* @param firstResult First result to return. Ignored if negative or {@code null}.
* @param maxResults Maximum number of results to return. Ignored if negative or {@code null}.
* @param search The name that should be matched
* @return Stream of top level groups in the realm. Never returns {@code null}.
*/
Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults);
/**
* Creates a new group with the given name in the given realm.

View file

@ -653,13 +653,17 @@ public interface RealmModel extends RoleContainerModel {
Long getGroupsCount(Boolean onlyTopGroups);
Long getGroupsCountByNameContaining(String search);
@Deprecated
/**
* @deprecated It is now preferable to use {@link GroupProvider} from a {@link KeycloakSession}
* Returns top level groups as a stream.
* @return Stream of {@link GroupModel}. Never returns {@code null}.
*/
Stream<GroupModel> getTopLevelGroupsStream();
@Deprecated
/**
* @deprecated It is now preferable to use {@link GroupProvider} from a {@link KeycloakSession}
* Returns top level groups as a stream.
* @param first {@code Integer} Index of the first desired group. Ignored if negative or {@code null}.
* @param max {@code Integer} Maximum number of returned groups. Ignored if negative or {@code null}.

View file

@ -92,6 +92,7 @@ import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error;
import org.keycloak.utils.GroupUtils;
import org.keycloak.validate.Validators;
/**
@ -459,9 +460,10 @@ public class AccountRestService {
@GET
@NoCache
@Produces(MediaType.APPLICATION_JSON)
//TODO GROUPS this isn't paginated
public Stream<GroupRepresentation> groupMemberships(@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
auth.require(AccountRoles.VIEW_GROUPS);
return ModelToRepresentation.toGroupHierarchy(user, !briefRepresentation);
return user.getGroupsStream().map(g -> ModelToRepresentation.toRepresentation(g, !briefRepresentation));
}
@Path("/applications")

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.services.resources.admin;
import jakarta.ws.rs.DefaultValue;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
@ -36,7 +37,6 @@ import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.ManagementPermissionReference;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.Urls;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
@ -59,6 +59,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
import org.keycloak.utils.GroupUtils;
/**
* @resource Groups
@ -94,11 +95,11 @@ public class GroupResource {
public GroupRepresentation getGroup() {
this.auth.groups().requireView(group);
GroupRepresentation rep = ModelToRepresentation.toGroupHierarchy(group, true);
GroupRepresentation rep = GroupUtils.toRepresentation(this.auth.groups(), group, true);
rep.setAccess(auth.groups().getAccess(group));
return rep;
return GroupUtils.populateSubGroupCount(group, rep);
}
/**
@ -134,7 +135,7 @@ public class GroupResource {
private Stream<GroupModel> siblings() {
if (group.getParentId() == null) {
return realm.getTopLevelGroupsStream();
return session.groups().getTopLevelGroupsStream(realm);
} else {
return group.getParent().getSubGroupsStream();
}
@ -150,6 +151,21 @@ public class GroupResource {
adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success();
}
@GET
@Path("children")
@NoCache
@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<GroupRepresentation> getSubGroups(@QueryParam("first") @DefaultValue("0") Integer first,
@QueryParam("max") @DefaultValue("10") Integer max,
@QueryParam("briefRepresentation") @DefaultValue("false") Boolean full) {
this.auth.groups().requireView(group);
boolean canViewGlobal = auth.groups().canView();
return group.getSubGroupsStream(first, max)
.filter(g -> canViewGlobal || auth.groups().canView(g))
.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, full)));
}
/**
* Set or create child. This will just set the parent if it exists. Create it and set the parent
@ -201,7 +217,7 @@ public class GroupResource {
}
adminEvent.resourcePath(session.getContext().getUri()).representation(rep).success();
GroupRepresentation childRep = ModelToRepresentation.toGroupHierarchy(child, true);
GroupRepresentation childRep = GroupUtils.toRepresentation(auth.groups(), child, true);
return builder.type(MediaType.APPLICATION_JSON_TYPE).entity(childRep).build();
} catch (ModelDuplicateException e) {
throw ErrorResponse.exists("Sibling group named '" + groupName + "' already exists.");

View file

@ -16,12 +16,26 @@
*/
package org.keycloak.services.resources.admin;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.annotations.cache.NoCache;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
@ -38,21 +52,7 @@ import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluato
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.SearchQueryUtils;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
/**
* @resource Groups
@ -94,21 +94,25 @@ public class GroupsResource {
GroupPermissionEvaluator groupsEvaluator = auth.groups();
groupsEvaluator.requireList();
Stream<GroupModel> stream = null;
Stream<GroupModel> stream;
if (Objects.nonNull(searchQuery)) {
Map<String, String> attributes = SearchQueryUtils.getFields(searchQuery);
stream = ModelToRepresentation.searchGroupModelsByAttributes(session, realm, !briefRepresentation, populateHierarchy, attributes, firstResult, maxResults);
stream = ModelToRepresentation.searchGroupModelsByAttributes(session, realm, attributes, firstResult, maxResults);
} else if (Objects.nonNull(search)) {
stream = ModelToRepresentation.searchForGroupModelByName(session, realm, !briefRepresentation, search.trim(), exact, firstResult, maxResults);
stream = session.groups().searchForGroupByNameStream(realm, search.trim(), exact, firstResult, maxResults);
} else if(Objects.nonNull(firstResult) && Objects.nonNull(maxResults)) {
stream = ModelToRepresentation.toGroupModelHierarchy(realm, !briefRepresentation, firstResult, maxResults);
stream = session.groups().getTopLevelGroupsStream(realm, firstResult, maxResults);
} else {
stream = realm.getTopLevelGroupsStream();
stream = session.groups().getTopLevelGroupsStream(realm);
}
if(populateHierarchy) {
return GroupUtils.populateGroupHierarchyFromSubGroups(session, realm, stream, !briefRepresentation, groupsEvaluator);
}
boolean canViewGlobal = groupsEvaluator.canView();
return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group))
.map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact, !briefRepresentation, false));
return stream
.filter(g -> canViewGlobal || groupsEvaluator.canView(g))
.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(groupsEvaluator, g, !briefRepresentation)));
}
/**
@ -139,6 +143,8 @@ public class GroupsResource {
@Operation( summary = "Returns the groups counts.")
public Map<String, Long> getGroupCount(@QueryParam("search") String search,
@QueryParam("top") @DefaultValue("false") boolean onlyTopGroups) {
GroupPermissionEvaluator groupsEvaluator = auth.groups();
groupsEvaluator.requireList();
Long results;
Map<String, Long> map = new HashMap<>();
if (Objects.nonNull(search)) {

View file

@ -117,6 +117,7 @@ import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.storage.DatastoreProvider;
import org.keycloak.storage.ExportImportManager;
import org.keycloak.storage.LegacyStoreSyncEvent;
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.ProfileHelper;
import org.keycloak.utils.ReservedCharValidator;
@ -1079,7 +1080,7 @@ public class RealmAdminResource {
}
auth.groups().requireView(found);
return ModelToRepresentation.toGroupHierarchy(found, true);
return ModelToRepresentation.toRepresentation(found, true);
}
/**

View file

@ -89,6 +89,7 @@ import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.ProfileHelper;
import jakarta.ws.rs.BadRequestException;
@ -982,12 +983,7 @@ public class UserResource {
@QueryParam("max") Integer maxResults,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
auth.users().requireView(user);
if (Objects.nonNull(search)) {
return ModelToRepresentation.searchForGroupByName(user, !briefRepresentation, search.trim(), firstResult, maxResults);
} else {
return ModelToRepresentation.toGroupHierarchy(user, !briefRepresentation, firstResult, maxResults);
}
return user.getGroupsStream(search, firstResult, maxResults).map(g -> ModelToRepresentation.toRepresentation(g, !briefRepresentation));
}
@GET

View file

@ -1,56 +1,88 @@
package org.keycloak.utils;
import java.util.Collections;
import java.util.stream.Collectors;
import org.keycloak.common.Profile;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator;
public class GroupUtils {
// Moved out from org.keycloak.admin.ui.rest.GroupsResource
public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact, boolean lazy) {
return toGroupHierarchy(groupsEvaluator, group, search, exact, true, lazy);
/**
* This method takes the provided groups and attempts to load their parents all the way to the root group while maintaining the hierarchy data
* for each GroupRepresentation object. Each resultant GroupRepresentation object in the stream should contain relevant subgroups to the originally
* provided groups
* @param session The active keycloak session
* @param realm The realm to operate on
* @param groups The groups that we want to populate the hierarchy for
* @return A stream of groups that contain all relevant groups from the root down with no extra siblings
*/
public static Stream<GroupRepresentation> populateGroupHierarchyFromSubGroups(KeycloakSession session, RealmModel realm, Stream<GroupModel> groups, boolean full, GroupPermissionEvaluator groupEvaluator) {
Map<String, GroupRepresentation> groupIdToGroups = new HashMap<>();
groups.forEach(group -> {
//TODO GROUPS do permissions work in such a way that if you can view the children you can definitely view the parents?
if(!groupEvaluator.canView() && !groupEvaluator.canView(group)) return;
GroupRepresentation currGroup = toRepresentation(groupEvaluator, group, full);
populateSubGroupCount(group, currGroup);
groupIdToGroups.putIfAbsent(currGroup.getId(), currGroup);
while(currGroup.getParentId() != null) {
GroupModel parentModel = session.groups().getGroupById(realm, currGroup.getParentId());
//TODO GROUPS not sure if this is even necessary but if somehow you can't view the parent we need to remove the child and move on
if(!groupEvaluator.canView() && !groupEvaluator.canView(parentModel)) {
groupIdToGroups.remove(currGroup.getId());
break;
}
GroupRepresentation parent = groupIdToGroups.computeIfAbsent(currGroup.getParentId(),
id -> toRepresentation(groupEvaluator, parentModel, full));
populateSubGroupCount(parentModel, parent);
GroupRepresentation finalCurrGroup = currGroup;
// check the parent for existing subgroups that match the group we're currently operating on and merge them if needed
Optional<GroupRepresentation> duplicateGroup = parent.getSubGroups() == null ?
Optional.empty() : parent.getSubGroups().stream().filter(g -> g.equals(finalCurrGroup)).findFirst();
if(duplicateGroup.isPresent()) {
duplicateGroup.get().merge(currGroup);
} else {
parent.getSubGroups().add(currGroup);
}
groupIdToGroups.remove(currGroup.getId());
currGroup = parent;
}
});
return groupIdToGroups.values().stream().sorted(Comparator.comparing(GroupRepresentation::getName));
}
public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact, boolean full, boolean lazy) {
GroupRepresentation rep = ModelToRepresentation.toRepresentation(group, full);
if (!lazy) {
rep.setSubGroups(group.getSubGroupsStream().filter(g ->
groupMatchesSearchOrIsPathElement(
g, search
)
).map(subGroup ->
ModelToRepresentation.toGroupHierarchy(
subGroup, full, search, exact
)
).collect(Collectors.toList()));
} else {
rep.setSubGroups(Collections.emptyList());
}
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) {
setAccess(groupsEvaluator, group, rep);
}
return rep;
/**
* This method's purpose is to look up the subgroup count of a Group and populate it on the representation. This has been kept separate from
* {@link #toRepresentation} in order to keep database lookups separate from a function that aims to only convert objects
* A way of cohesively ensuring that a GroupRepresentation always has a group count should be considered
*
* @param group model
* @param representation group representation
* @return
*/
public static GroupRepresentation populateSubGroupCount(GroupModel group, GroupRepresentation representation) {
representation.setSubGroupCount(group.getSubGroupsCount());
return representation;
}
//From org.keycloak.admin.ui.rest.GroupsResource
// set fine-grained access for each group in the tree
private static void setAccess(GroupPermissionEvaluator groupsEvaluator, GroupModel groupTree, GroupRepresentation rootGroup) {
if (rootGroup == null) return;
rootGroup.setAccess(groupsEvaluator.getAccess(groupTree));
rootGroup.getSubGroups().stream().forEach(subGroup -> {
GroupModel foundGroupModel = groupTree.getSubGroupsStream().filter(g -> g.getId().equals(subGroup.getId())).findFirst().get();
setAccess(groupsEvaluator, foundGroupModel, subGroup);
});
public static GroupRepresentation toRepresentation(GroupPermissionEvaluator groupsEvaluator, GroupModel groupTree, boolean full) {
GroupRepresentation rep = ModelToRepresentation.toRepresentation(groupTree, full);
rep.setAccess(groupsEvaluator.getAccess(groupTree));
return rep;
}
private static boolean groupMatchesSearchOrIsPathElement(GroupModel group, String search) {

View file

@ -20,6 +20,8 @@ import org.jboss.logging.Logger;
import org.keycloak.admin.client.resource.AuthorizationResource;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.GroupResource;
import org.keycloak.admin.client.resource.GroupsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.RoleResource;
import org.keycloak.admin.client.resource.UserResource;
@ -256,9 +258,9 @@ public class ApiUtil {
}
}
public static boolean groupContainsSubgroup(GroupRepresentation group, GroupRepresentation subgroup) {
public static boolean groupContainsSubgroup(GroupResource groupsResource, GroupRepresentation subgroup) {
boolean contains = false;
for (GroupRepresentation sg : group.getSubGroups()) {
for (GroupRepresentation sg : groupsResource.getSubGroups(null,null, true)) {
if (subgroup.getId().equals(sg.getId())) {
contains = true;
break;

View file

@ -17,6 +17,8 @@
package org.keycloak.testsuite.admin.concurrency;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.junit.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
@ -233,11 +235,18 @@ public class ConcurrencyTest extends AbstractConcurrencyTest {
c = realm.groups().group(id).toRepresentation();
assertNotNull(c);
assertTrue("Group " + name + " [" + id + "] " + " not found in group list",
realm.groups().groups().stream()
.map(GroupRepresentation::getName)
.filter(Objects::nonNull)
.anyMatch(name::equals));
boolean retry = true;
int i = 0;
do {
List<String> groups = realm.groups().groups().stream()
.map(GroupRepresentation::getName)
.filter(Objects::nonNull)
.collect(Collectors.toList());
retry = !groups.contains(name);
i++;
} while(retry && i < 3);
assertFalse("Group " + name + " [" + id + "] " + " not found in group list", retry);
}
}

View file

@ -58,6 +58,7 @@ public class GroupSearchTest extends AbstractGroupTest {
GroupRepresentation group3;
GroupRepresentation parentGroup;
GroupRepresentation childGroup;
GroupRepresentation secondChildGroup;
@Before
public void init() {
@ -66,6 +67,7 @@ public class GroupSearchTest extends AbstractGroupTest {
group3 = new GroupRepresentation();
parentGroup = new GroupRepresentation();
childGroup = new GroupRepresentation();
secondChildGroup = new GroupRepresentation();
group1.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList(ATTR_ORG_VAL));
@ -82,7 +84,7 @@ public class GroupSearchTest extends AbstractGroupTest {
put(ATTR_QUOTES_NAME, Collections.singletonList(ATTR_QUOTES_VAL));
}});
childGroup.setAttributes(new HashMap<>() {{
parentGroup.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList("parentOrg"));
}});
@ -95,6 +97,7 @@ public class GroupSearchTest extends AbstractGroupTest {
group3.setName(GROUP3);
parentGroup.setName(PARENT_GROUP);
childGroup.setName(CHILD_GROUP);
secondChildGroup.setName(CHILD_GROUP + "2");
}
public RealmResource testRealmResource() {

View file

@ -40,7 +40,6 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.updaters.Creator;
import org.keycloak.testsuite.util.AdminEventPaths;
import org.keycloak.testsuite.util.ClientBuilder;
@ -83,7 +82,6 @@ import org.keycloak.models.AdminRoles;
import org.keycloak.models.GroupModel;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.testsuite.Assert.assertNames;
@ -426,15 +424,15 @@ public class GroupTest extends AbstractGroupTest {
topGroup = realm.getGroupByPath("/top");
assertEquals(1, topGroup.getRealmRoles().size());
assertTrue(topGroup.getRealmRoles().contains("topRole"));
assertEquals(1, topGroup.getSubGroups().size());
assertEquals(1, realm.groups().group(topGroup.getId()).getSubGroups(0, null, false).size());
level2Group = topGroup.getSubGroups().get(0);
level2Group = realm.getGroupByPath("/top/level2");
assertEquals("level2", level2Group.getName());
assertEquals(1, level2Group.getRealmRoles().size());
assertTrue(level2Group.getRealmRoles().contains("level2Role"));
assertEquals(1, level2Group.getSubGroups().size());
assertEquals(1, realm.groups().group(level2Group.getId()).getSubGroups(0, null, false).size());
level3Group = level2Group.getSubGroups().get(0);
level3Group = realm.getGroupByPath("/top/level2/level3");
assertEquals("level3", level3Group.getName());
assertEquals(1, level3Group.getRealmRoles().size());
assertTrue(level3Group.getRealmRoles().contains("level3Role"));
@ -559,10 +557,11 @@ public class GroupTest extends AbstractGroupTest {
response.close();
// Assert "mygroup2" was moved
group1 = realm.groups().group(group1.getId()).toRepresentation();
group2 = realm.groups().group(group2.getId()).toRepresentation();
assertNames(group1.getSubGroups(), "mygroup2");
assertEquals("/mygroup1/mygroup2", group2.getPath());
List<GroupRepresentation> group1Children = realm.groups().group(group1.getId()).getSubGroups(0, 10, false);
List<GroupRepresentation> group2Children = realm.groups().group(group2.getId()).getSubGroups(0, 10, false);
assertNames(group1Children, "mygroup2");
assertEquals("/mygroup1/mygroup2", realm.groups().group(group2.getId()).toRepresentation().getPath());
assertAdminEvents.clear();
@ -583,10 +582,10 @@ public class GroupTest extends AbstractGroupTest {
response.close();
// Assert "mygroup2" was moved
group1 = realm.groups().group(group1.getId()).toRepresentation();
group2 = realm.groups().group(group2.getId()).toRepresentation();
assertTrue(group1.getSubGroups().isEmpty());
assertEquals("/mygroup2", group2.getPath());
group1Children = realm.groups().group(group1.getId()).getSubGroups(0, 10, false);
group2Children = realm.groups().group(group2.getId()).getSubGroups(0, 10, false);
assertEquals(0, group1Children.size());
assertEquals("/mygroup2", realm.groups().group(group2.getId()).toRepresentation().getPath());
}
@Test
@ -1160,7 +1159,19 @@ public class GroupTest extends AbstractGroupTest {
assertNotNull(group0);
assertEquals(2,group0.getSubGroups().size());
assertThat(group0.getSubGroups().stream().map(GroupRepresentation::getName).collect(Collectors.toList()), Matchers.containsInAnyOrder("group1111", "group111111"));
assertEquals(new Long(search.size()), realm.groups().count("group11").get("count"));
assertEquals(countLeafGroups(search), realm.groups().count("group11").get("count"));
}
private Long countLeafGroups(List<GroupRepresentation> search) {
long counter = 0;
for(GroupRepresentation group : search) {
if(group.getSubGroups().isEmpty()) {
counter += 1;
continue;
}
counter += countLeafGroups(group.getSubGroups());
}
return counter;
}
@Test
@ -1192,7 +1203,7 @@ public class GroupTest extends AbstractGroupTest {
Comparator<GroupRepresentation> compareByName = Comparator.comparing(GroupRepresentation::getName);
// Assert that all groups are returned in order
List<GroupRepresentation> allGroups = realm.groups().groups();
List<GroupRepresentation> allGroups = realm.groups().groups(0, 100);
assertEquals(40, allGroups.size());
assertTrue(Comparators.isInStrictOrder(allGroups, compareByName));

View file

@ -35,6 +35,7 @@ import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authorization.client.AuthorizationDeniedException;
import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.authorization.model.Resource;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
@ -259,7 +260,7 @@ public class GroupNamePolicyTest extends AbstractAuthzTest {
continue;
}
GroupRepresentation group = getGroup(part, parent.getSubGroups());
GroupRepresentation group = getGroup(part, realm.groups().group(parent.getId()).getSubGroups(0, 10, true));
if (path.endsWith(group.getName())) {
return group;
@ -272,12 +273,13 @@ public class GroupNamePolicyTest extends AbstractAuthzTest {
}
private GroupRepresentation getGroup(String name, List<GroupRepresentation> groups) {
RealmResource realm = getRealm();
for (GroupRepresentation group : groups) {
if (name.equals(group.getName())) {
return group;
}
GroupRepresentation child = getGroup(name, group.getSubGroups());
GroupRepresentation child = getGroup(name, realm.groups().group(group.getId()).getSubGroups(0, 10, true));
if (child != null && name.equals(child.getName())) {
return child;

View file

@ -235,7 +235,7 @@ public class GroupPathPolicyTest extends AbstractAuthzTest {
continue;
}
GroupRepresentation group = getGroup(part, parent.getSubGroups());
GroupRepresentation group = getGroup(part, realm.groups().group(parent.getId()).getSubGroups(0, 10, true));
if (path.endsWith(group.getName())) {
return group;
@ -248,12 +248,13 @@ public class GroupPathPolicyTest extends AbstractAuthzTest {
}
private GroupRepresentation getGroup(String name, List<GroupRepresentation> groups) {
RealmResource realm = getRealm();
for (GroupRepresentation group : groups) {
if (name.equals(group.getName())) {
return group;
}
GroupRepresentation child = getGroup(name, group.getSubGroups());
GroupRepresentation child = getGroup(name, realm.groups().group(group.getId()).getSubGroups(0, 10, true));
if (child != null && name.equals(child.getName())) {
return child;

View file

@ -4,6 +4,7 @@ import org.apache.commons.lang.RandomStringUtils;
import org.junit.Before;
import org.keycloak.admin.client.resource.GroupResource;
import org.keycloak.admin.client.resource.GroupsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.ContainerInfo;
@ -45,6 +46,10 @@ public class GroupInvalidationClusterTest extends AbstractInvalidationClusterTes
return getAdminClientFor(node).realm(testRealmName).groups();
}
protected RealmResource realm(ContainerInfo node) {
return getAdminClientFor(node).realm(testRealmName);
}
@Override
protected GroupResource entityResource(GroupRepresentation group, ContainerInfo node) {
return entityResource(group.getId(), node);
@ -129,7 +134,7 @@ public class GroupInvalidationClusterTest extends AbstractInvalidationClusterTes
parentGroup = readEntityOnCurrentFailNode(parentGroup);
group = readEntityOnCurrentFailNode(group);
assertTrue(ApiUtil.groupContainsSubgroup(parentGroup, group));
assertTrue(ApiUtil.groupContainsSubgroup(entityResourceOnCurrentFailNode(parentGroup), group));
assertEquals(parentGroup.getPath() + "/" + group.getName(), group.getPath());
verifyEntityUpdateDuringFailover(group, backendFailover);
@ -149,8 +154,8 @@ public class GroupInvalidationClusterTest extends AbstractInvalidationClusterTes
// Verify same child groups on both nodes
GroupRepresentation parentGroupOnOtherNode = readEntityOnCurrentFailNode(parentGroup);
assertNames(parentGroup.getSubGroups(), group.getName(), "childGroup2");
assertNames(parentGroupOnOtherNode.getSubGroups(), group.getName(), "childGroup2");
assertNames(entityResourceOnCurrentFailNode(parentGroup).getSubGroups(0, 20, true), group.getName(), "childGroup2");
assertNames(entityResourceOnCurrentFailNode(parentGroupOnOtherNode).getSubGroups(0, 20, true), group.getName(), "childGroup2");
// Remove childGroup2
deleteEntityOnCurrentFailNode(childGroup2);