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
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); 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) { for (GroupPolicyRepresentation.GroupDefinition definition : groups) {
GroupModel group = null; GroupModel group = null;

View File

@@ -17,23 +17,35 @@
package org.keycloak.representations.idm; package org.keycloak.representations.idm;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $ * @version $Revision: 1 $
*/ */
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class GroupRepresentation { 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 id;
protected String name; protected String name;
protected String path; 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 Map<String, List<String>> attributes;
protected List<String> realmRoles; protected List<String> realmRoles;
protected Map<String, List<String>> clientRoles; protected Map<String, List<String>> clientRoles;
protected List<GroupRepresentation> subGroups;
private Map<String, Boolean> access; private Map<String, Boolean> access;
public String getId() { public String getId() {
@@ -60,6 +72,22 @@ public class GroupRepresentation {
this.path = path; 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() { public List<String> getRealmRoles() {
return realmRoles; return realmRoles;
} }
@@ -92,6 +120,9 @@ public class GroupRepresentation {
} }
public List<GroupRepresentation> getSubGroups() { public List<GroupRepresentation> getSubGroups() {
if(subGroups == null) {
subGroups = new ArrayList<>();
}
return subGroups; return subGroups;
} }
@@ -106,4 +137,49 @@ public class GroupRepresentation {
public void setAccess(Map<String, Boolean> access) { public void setAccess(Map<String, Boolean> access) {
this.access = 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 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 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. 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) { if (parentGroup == null) {
parentGroup = getKcGroupsPathGroup(realm); 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 @DELETE
void remove(); 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 * 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 SessionsPage from "../support/pages/admin-ui/manage/sessions/SessionsPage";
import CommonPage from "../support/pages/CommonPage"; import CommonPage from "../support/pages/CommonPage";
import ListingPage from "../support/pages/admin-ui/ListingPage"; 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 { keycloakBefore } from "../support/util/keycloak_hooks";
import PageObject from "../support/pages/admin-ui/components/PageObject";
const loginPage = new LoginPage(); const loginPage = new LoginPage();
const sidebarPage = new SidebarPage(); const sidebarPage = new SidebarPage();
const sessionsPage = new SessionsPage(); const sessionsPage = new SessionsPage();
const commonPage = new CommonPage(); const commonPage = new CommonPage();
const listingPage = new ListingPage(); const listingPage = new ListingPage();
const groupPage = new GroupPage(); const page = new PageObject();
describe("Sessions test", () => { describe("Sessions test", () => {
const admin = "admin"; const admin = "admin";
@@ -42,12 +42,12 @@ describe("Sessions test", () => {
it("search existing session", () => { it("search existing session", () => {
listingPage.searchItem(admin, false); listingPage.searchItem(admin, false);
listingPage.itemExist(admin, true); listingPage.itemExist(admin, true);
groupPage.assertNoSearchResultsMessageExist(false); page.assertEmptyStateExist(false);
}); });
it("search non-existant session", () => { it("search non-existant session", () => {
listingPage.searchItem("non-existant-session", false); listingPage.searchItem("non-existant-session", false);
groupPage.assertNoSearchResultsMessageExist(true); page.assertEmptyStateExist(true);
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -183,7 +183,7 @@ export const GroupTree = ({
useFetch( useFetch(
async () => { async () => {
const groups = await fetchAdminUI<GroupRepresentation[]>( const groups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups", "groups",
Object.assign( Object.assign(
{ {
first: `${first}`, first: `${first}`,
@@ -197,9 +197,8 @@ export const GroupTree = ({
let subGroups: GroupRepresentation[] = []; let subGroups: GroupRepresentation[] = [];
if (activeItem) { if (activeItem) {
subGroups = await fetchAdminUI<GroupRepresentation[]>( subGroups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups/subgroup", `groups/${activeItem.id}/children`,
{ {
id: activeItem.id!,
first: `${firstSub}`, first: `${firstSub}`,
max: `${SUBGROUP_COUNT}`, 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`) - Count (`GET /{realm}/groups/count`)
- List members (`GET /{realm}/groups/{id}/members`) - List members (`GET /{realm}/groups/{id}/members`)
- Set or create child (`POST /{realm}/groups/{id}/children`) - Set or create child (`POST /{realm}/groups/{id}/children`)
- Get children (`GET /{realm}/groups/{id}/children`)
### Group role-mapping ### Group role-mapping

View File

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

View File

@@ -7,13 +7,26 @@ import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
import type UserRepresentation from "../defs/userRepresentation.js"; import type UserRepresentation from "../defs/userRepresentation.js";
import Resource from "./resource.js"; import Resource from "./resource.js";
export interface GroupQuery { interface Query {
search?: string;
exact?: boolean;
}
interface PaginatedQuery {
first?: number; first?: number;
max?: number; max?: number;
search?: string; }
interface SummarizedQuery {
briefRepresentation?: boolean; briefRepresentation?: boolean;
} }
export type GroupQuery = Query & PaginatedQuery & SummarizedQuery;
export type SubGroupQuery = PaginatedQuery &
SummarizedQuery & {
parentId: string;
};
export interface GroupCountQuery { export interface GroupCountQuery {
search?: string; search?: string;
top?: boolean; top?: boolean;
@@ -22,6 +35,7 @@ export interface GroupCountQuery {
export class Groups extends Resource<{ realm?: string }> { export class Groups extends Resource<{ realm?: string }> {
public find = this.makeRequest<GroupQuery, GroupRepresentation[]>({ public find = this.makeRequest<GroupQuery, GroupRepresentation[]>({
method: "GET", method: "GET",
queryParamKeys: ["search", "exact", "briefRepresentation", "first", "max"],
}); });
public create = this.makeRequest<GroupRepresentation, { id: string }>({ public create = this.makeRequest<GroupRepresentation, { id: string }>({
@@ -112,6 +126,19 @@ export class Groups extends Resource<{ realm?: string }> {
urlParamKeys: ["id"], 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 * 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 GroupRepresentation from "../src/defs/groupRepresentation.js";
import type RoleRepresentation from "../src/defs/roleRepresentation.js"; import type RoleRepresentation from "../src/defs/roleRepresentation.js";
import { credentials } from "./constants.js"; import { credentials } from "./constants.js";
import { SubGroupQuery } from "../src/resources/groups.js";
const expect = chai.expect; const expect = chai.expect;
@@ -93,11 +94,20 @@ describe("Groups", () => {
const group = (await kcAdminClient.groups.findOne({ const group = (await kcAdminClient.groups.findOne({
id: groupId!, id: groupId!,
}))!; }))!;
expect(group.subGroups![0]).to.deep.include({ expect(group).to.be.ok;
id: childGroup.id, });
name: groupName,
path: `/${group.name}/${groupName}`, 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); 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 @Override
public void setParent(GroupModel group) { public void setParent(GroupModel group) {

View File

@@ -989,7 +989,6 @@ public class RealmCacheSession implements CacheRealmProvider {
public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) { public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) {
return getGroupDelegate().getGroupsCount(realm, onlyTopGroups); return getGroupDelegate().getGroupsCount(realm, onlyTopGroups);
} }
@Override @Override
public long getClientsCount(RealmModel realm) { public long getClientsCount(RealmModel realm) {
return getClientDelegate().getClientsCount(realm); return getClientDelegate().getClientsCount(realm);
@@ -1006,49 +1005,12 @@ public class RealmCacheSession implements CacheRealmProvider {
} }
@Override @Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) { public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer first, Integer max) {
String cacheKey = getTopGroupsQueryCacheKey(realm.getId()); String cacheKey = getTopGroupsQueryCacheKey(realm.getId() + search + first + max);
boolean queryDB = invalidations.contains(cacheKey) || listInvalidations.contains(realm.getId()); boolean queryDB = invalidations.contains(cacheKey) || listInvalidations.contains(cacheKey)
|| listInvalidations.contains(realm.getId());
if (queryDB) { if (queryDB) {
return getGroupDelegate().getTopLevelGroupsStream(realm); return getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max);
}
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);
} }
GroupListQuery query = cache.get(cacheKey, GroupListQuery.class); GroupListQuery query = cache.get(cacheKey, GroupListQuery.class);
@@ -1058,7 +1020,7 @@ public class RealmCacheSession implements CacheRealmProvider {
if (Objects.isNull(query)) { if (Objects.isNull(query)) {
Long loaded = cache.getCurrentRevision(cacheKey); 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(); if (model.isEmpty()) return Stream.empty();
Set<String> ids = new HashSet<>(); Set<String> ids = new HashSet<>();
for (GroupModel client : model) ids.add(client.getId()); 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.common.util.MultivaluedHashMap;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel; import org.keycloak.models.RoleModel;
import org.keycloak.models.jpa.entities.GroupAttributeEntity; import org.keycloak.models.jpa.entities.GroupAttributeEntity;
@@ -38,6 +39,7 @@ import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing; import static org.keycloak.utils.StreamsUtil.closing;
/** /**
@@ -121,10 +123,35 @@ public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> {
@Override @Override
public Stream<GroupModel> getSubGroupsStream() { public Stream<GroupModel> getSubGroupsStream() {
TypedQuery<String> query = em.createNamedQuery("getGroupIdsByParent", String.class); return getSubGroupsStream("", false, -1, -1);
query.setParameter("realm", group.getRealm()); }
query.setParameter("parent", group.getId());
return closing(query.getResultStream().map(realm::getGroupById).filter(Objects::nonNull)); @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 @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.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing; import static org.keycloak.utils.StreamsUtil.closing;
import java.util.ArrayList;
import java.util.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.EntityManager;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import jakarta.persistence.TypedQuery; import jakarta.persistence.TypedQuery;
@@ -39,7 +30,15 @@ import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root; 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.hibernate.Session;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.util.Time; 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.RealmEntity;
import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity; import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity;
import org.keycloak.models.jpa.entities.RoleEntity; import org.keycloak.models.jpa.entities.RoleEntity;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @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.clientScopes().removeClientScopes(adapter);
session.roles().removeRoles(adapter); session.roles().removeRoles(adapter);
adapter.getTopLevelGroupsStream().forEach(adapter::removeGroup); session.groups().getTopLevelGroupsStream(adapter).forEach(adapter::removeGroup);
num = em.createNamedQuery("removeClientInitialAccessByRealm") num = em.createNamedQuery("removeClientInitialAccessByRealm")
.setParameter("realm", realm).executeUpdate(); .setParameter("realm", realm).executeUpdate();
@@ -437,8 +437,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override @Override
public GroupModel getGroupByName(RealmModel realm, GroupModel parent, String name) { public GroupModel getGroupByName(RealmModel realm, GroupModel parent, String name) {
TypedQuery<String> query = em.createNamedQuery("getGroupIdByNameAndParent", String.class); TypedQuery<String> query = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
query.setParameter("name", name); query.setParameter("search", name);
query.setParameter("realm", realm.getId()); query.setParameter("realm", realm.getId());
query.setParameter("parent", parent != null ? parent.getId() : GroupEntity.TOP_PARENT_ID); query.setParameter("parent", parent != null ? parent.getId() : GroupEntity.TOP_PARENT_ID);
List<String> entities = query.getResultList(); List<String> entities = query.getResultList();
@@ -566,7 +566,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override @Override
public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) { public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) {
if(Objects.equals(onlyTopGroups, Boolean.TRUE)) { if(Objects.equals(onlyTopGroups, Boolean.TRUE)) {
return em.createNamedQuery("getTopLevelGroupCount", Long.class) return em.createNamedQuery("getGroupCountByParent", Long.class)
.setParameter("realm", realm.getId()) .setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID) .setParameter("parent", GroupEntity.TOP_PARENT_ID)
.getSingleResult(); .getSingleResult();
@@ -603,21 +603,23 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
} }
@Override @Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) { public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults) {
return getTopLevelGroupsStream(realm, null, null); TypedQuery<String> groupsQuery;
} if(Boolean.TRUE.equals(exact)) {
groupsQuery = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
} else {
groupsQuery = em.createNamedQuery("getGroupIdsByParentAndNameContaining", String.class);
}
@Override groupsQuery.setParameter("realm", realm.getId())
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer first, Integer max) { .setParameter("parent", GroupEntity.TOP_PARENT_ID)
TypedQuery<String> groupsQuery = em.createNamedQuery("getTopLevelGroupIds", String.class) .setParameter("search", search);
.setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID);
return closing(paginateQuery(groupsQuery, first, max).getResultStream() return closing(paginateQuery(groupsQuery, firstResult, maxResults).getResultStream()
.map(realm::getGroupById) .map(realm::getGroupById)
// In concurrent tests, the group might be deleted in another thread, therefore, skip those null values. // In concurrent tests, the group might be deleted in another thread, therefore, skip those null values.
.filter(Objects::nonNull) .filter(Objects::nonNull)
.sorted(GroupModel.COMPARE_BY_NAME) .sorted(GroupModel.COMPARE_BY_NAME)
); );
} }
@@ -648,6 +650,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
session.users().preRemove(realm, group); session.users().preRemove(realm, group);
realm.removeDefaultGroup(group); realm.removeDefaultGroup(group);
group.getSubGroupsStream().forEach(realm::removeGroup); group.getSubGroupsStream().forEach(realm::removeGroup);
GroupEntity groupEntity = em.find(GroupEntity.class, group.getId(), LockModeType.PESSIMISTIC_WRITE); 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); .setParameter("search", search);
Stream<String> groups = paginateQuery(query, first, max).getResultStream(); Stream<String> groups = paginateQuery(query, first, max).getResultStream();
return closing(groups.map(id -> { return closing(groups.map(id -> session.groups().getGroupById(realm, id)).sorted(GroupModel.COMPARE_BY_NAME).distinct());
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());
} }
@Override @Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) { public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
Map<String, String> filteredAttributes = groupSearchableAttributes == null || groupSearchableAttributes.isEmpty() 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); TypedQuery<GroupEntity> query = em.createQuery(queryBuilder);
return closing(paginateQuery(query, firstResult, maxResults).getResultStream()) return closing(paginateQuery(query, firstResult, maxResults).getResultStream())
.map(g -> session.groups().getGroupById(realm, g.getId())); .map(g -> new GroupAdapter(realm, em, g));
} }
@Override @Override

View File

@@ -29,16 +29,16 @@ import java.util.LinkedList;
*/ */
@NamedQueries({ @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="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="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="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="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="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="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="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="getGroupCountByParent", 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")
}) })
@Entity @Entity
@Table(name="KEYCLOAK_GROUP", @Table(name="KEYCLOAK_GROUP",

View File

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

View File

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

View File

@@ -17,6 +17,11 @@
package org.keycloak.models.map.group; 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.jboss.logging.Logger;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.GroupModel.SearchableFields; 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.DeepCloner;
import org.keycloak.models.map.common.HasRealmId; import org.keycloak.models.map.common.HasRealmId;
import org.keycloak.models.map.storage.MapStorage; import org.keycloak.models.map.storage.MapStorage;
import org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator; import org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator;
import org.keycloak.models.map.storage.QueryParameters; import org.keycloak.models.map.storage.QueryParameters;
import org.keycloak.models.map.storage.criteria.DefaultModelCriteria; import org.keycloak.models.map.storage.criteria.DefaultModelCriteria;
import org.keycloak.models.utils.KeycloakModelUtils; 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.common.util.StackUtil.getShortStackTrace;
import static org.keycloak.models.map.common.AbstractMapProviderFactory.MapProviderObjectType.GROUP_AFTER_REMOVE; 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) return storeWithRealm(realm).read(queryParameters)
.map(entityToAdapterFunc(realm)) .map(entityToAdapterFunc(realm));
;
} }
@Override @Override
@@ -168,7 +167,14 @@ public class MapGroupProvider implements GroupProvider {
@Override @Override
public Long getGroupsCountByNameContaining(RealmModel realm, String search) { 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 @Override
@@ -181,21 +187,21 @@ public class MapGroupProvider implements GroupProvider {
} }
@Override @Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm) { public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults) {
LOG.tracef("getTopLevelGroupsStream(%s)%s", realm, getShortStackTrace()); LOG.tracef("getTopLevelGroupsStream(%s, %s,%s, %s,%s)%s", realm, search, exact, firstResult, maxResults, getShortStackTrace());
return getGroupsStreamInternal(realm,
(DefaultModelCriteria<GroupModel> mcb) -> mcb.compare(SearchableFields.PARENT_ID, Operator.NOT_EXISTS),
null
);
}
@Override DefaultModelCriteria<GroupModel> mcb = criteria();
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, Integer firstResult, Integer maxResults) { mcb = mcb.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId())
LOG.tracef("getTopLevelGroupsStream(%s, %s, %s)%s", realm, firstResult, maxResults, getShortStackTrace()); .compare(SearchableFields.PARENT_ID, Operator.NOT_EXISTS);
return getGroupsStreamInternal(realm, if(Boolean.TRUE.equals(exact)) {
(DefaultModelCriteria<GroupModel> mcb) -> mcb.compare(SearchableFields.PARENT_ID, Operator.NOT_EXISTS), mcb.compare(SearchableFields.NAME, Operator.EQ,search);
qp -> qp.offset(firstResult).limit(maxResults) } else {
); mcb.compare(SearchableFields.NAME, Operator.ILIKE, "%" + search + "%");
}
return storeWithRealm(realm).read(withCriteria(mcb).pagination(firstResult, maxResults, SearchableFields.NAME))
.map(entityToAdapterFunc(realm));
} }
@Override @Override
@@ -214,14 +220,7 @@ public class MapGroupProvider implements GroupProvider {
return storeWithRealm(realm).read(withCriteria(mcb).pagination(firstResult, maxResults, SearchableFields.NAME)) return storeWithRealm(realm).read(withCriteria(mcb).pagination(firstResult, maxResults, SearchableFields.NAME))
.map(MapGroupEntity::getId) .map(entityToAdapterFunc(realm));
.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();
} }
@Override @Override

View File

@@ -61,7 +61,14 @@ public class MapGroupProviderFactory extends AbstractMapProviderFactory<MapGroup
GroupModel group = (GroupModel) params[1]; GroupModel group = (GroupModel) params[1];
realm.removeDefaultGroup(group); 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) { } else if (type == GROUP_AFTER_REMOVE) {
session.getKeycloakSessionFactory().publish(new GroupModel.GroupRemovedEvent() { session.getKeycloakSessionFactory().publish(new GroupModel.GroupRemovedEvent() {
@Override public RealmModel getRealm() { return (RealmModel) params[0]; } @Override public RealmModel getRealm() { return (RealmModel) params[0]; }

View File

@@ -40,11 +40,6 @@ public final class AdminExtResource {
return new EffectiveRoleMappingResource(session, realm, auth); return new EffectiveRoleMappingResource(session, realm, auth);
} }
@Path("/groups")
public GroupsResource groups() {
return new GroupsResource(session, realm, auth);
}
@Path("/sessions") @Path("/sessions")
public SessionsResource sessions() { public SessionsResource sessions() {
return new SessionsResource(session, realm, auth); 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.PolicyStore;
import org.keycloak.authorization.store.StoreFactory; import org.keycloak.authorization.store.StoreFactory;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
@@ -48,7 +49,6 @@ import org.keycloak.representations.idm.*;
import org.keycloak.representations.idm.authorization.*; import org.keycloak.representations.idm.authorization.*;
import org.keycloak.storage.StorageId; import org.keycloak.storage.StorageId;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StreamsUtil;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
import java.io.IOException; import java.io.IOException;
@@ -131,6 +131,7 @@ public class ModelToRepresentation {
rep.setId(group.getId()); rep.setId(group.getId());
rep.setName(group.getName()); rep.setName(group.getName());
rep.setPath(buildGroupPath(group)); rep.setPath(buildGroupPath(group));
rep.setParentId(group.getParentId());
if (!full) return rep; if (!full) return rep;
// Role mappings // Role mappings
Set<RoleModel> roles = group.getRoleMappingsStream().collect(Collectors.toSet()); Set<RoleModel> roles = group.getRoleMappingsStream().collect(Collectors.toSet());
@@ -153,70 +154,17 @@ public class ModelToRepresentation {
return rep; return rep;
} }
public static Stream<GroupRepresentation> searchGroupsByAttributes(KeycloakSession session, RealmModel realm, boolean full, boolean populateHierarchy, Map<String,String> attributes, Integer first, Integer max) { public static Stream<GroupModel> searchGroupModelsByAttributes(KeycloakSession session, RealmModel realm, Map<String,String> attributes, Integer first, Integer max) {
Stream<GroupModel> groups = searchGroupModelsByAttributes(session, realm, full, populateHierarchy, attributes, first, max); return session.groups().searchGroupsByAttributes(realm, 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, boolean full, boolean populateHierarchy, Map<String,String> attributes, Integer first, Integer max) { @Deprecated
Stream<GroupModel> groups = session.groups().searchGroupsByAttributes(realm, attributes, first, max); public static Stream<GroupRepresentation> toGroupHierarchy(KeycloakSession session, RealmModel realm, boolean full) {
if(populateHierarchy) { return session.groups().getTopLevelGroupsStream(realm, null, null)
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()
.map(g -> toGroupHierarchy(g, full)); .map(g -> toGroupHierarchy(g, full));
} }
public static Stream<GroupRepresentation> toGroupHierarchy(UserModel user, boolean full) { @Deprecated
return user.getGroupsStream()
.map(group -> toRepresentation(group, full));
}
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full) { public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full) {
return toGroupHierarchy(group, full, (String) null); return toGroupHierarchy(group, full, (String) null);
} }
@@ -226,6 +174,11 @@ public class ModelToRepresentation {
return toGroupHierarchy(group, full, search, false); 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) { public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full, String search, Boolean exact) {
GroupRepresentation rep = toRepresentation(group, full); GroupRepresentation rep = toRepresentation(group, full);
List<GroupRepresentation> subGroups = group.getSubGroupsStream() List<GroupRepresentation> subGroups = group.getSubGroupsStream()
@@ -235,14 +188,6 @@ public class ModelToRepresentation {
return rep; 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) { private static boolean groupMatchesSearchOrIsPathElement(GroupModel group, String search, Boolean exact) {
if (StringUtil.isBlank(search)) { if (StringUtil.isBlank(search)) {
return true; return true;
@@ -554,7 +499,7 @@ public class ModelToRepresentation {
if (internal) { if (internal) {
exportAuthenticationFlows(session, realm, rep); exportAuthenticationFlows(session, realm, rep);
exportRequiredActions(realm, rep); exportRequiredActions(realm, rep);
exportGroups(realm, rep); exportGroups(session, realm, rep);
} }
session.clientPolicy().updateRealmRepresentationFromModel(realm, rep); session.clientPolicy().updateRealmRepresentationFromModel(realm, rep);
@@ -586,9 +531,8 @@ public class ModelToRepresentation {
return a; return a;
} }
public static void exportGroups(KeycloakSession session, RealmModel realm, RealmRepresentation rep) {
public static void exportGroups(RealmModel realm, RealmRepresentation rep) { rep.setGroups(toGroupHierarchy(session, realm, true).collect(Collectors.toList()));
rep.setGroups(toGroupHierarchy(realm, true).collect(Collectors.toList()));
} }
public static void exportAuthenticationFlows(KeycloakSession session, RealmModel realm, RealmRepresentation rep) { public static void exportAuthenticationFlows(KeycloakSession session, RealmModel realm, RealmRepresentation rep) {

View File

@@ -110,6 +110,69 @@ public interface GroupModel extends RoleMapperModel {
*/ */
Stream<GroupModel> getSubGroupsStream(); 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 * 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. * @param realm Realm.
* @return Stream of all top level groups in the realm. Never returns {@code null}. * @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. * 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}. * @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}. * @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. * 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 getGroupsCount(Boolean onlyTopGroups);
Long getGroupsCountByNameContaining(String search); 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. * Returns top level groups as a stream.
* @return Stream of {@link GroupModel}. Never returns {@code null}. * @return Stream of {@link GroupModel}. Never returns {@code null}.
*/ */
Stream<GroupModel> getTopLevelGroupsStream(); Stream<GroupModel> getTopLevelGroupsStream();
@Deprecated
/** /**
* @deprecated It is now preferable to use {@link GroupProvider} from a {@link KeycloakSession}
* Returns top level groups as a stream. * Returns top level groups as a stream.
* @param first {@code Integer} Index of the first desired group. Ignored if negative or {@code null}. * @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}. * @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.EventAuditingAttributeChangeListener;
import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error; import org.keycloak.userprofile.ValidationException.Error;
import org.keycloak.utils.GroupUtils;
import org.keycloak.validate.Validators; import org.keycloak.validate.Validators;
/** /**
@@ -459,9 +460,10 @@ public class AccountRestService {
@GET @GET
@NoCache @NoCache
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
//TODO GROUPS this isn't paginated
public Stream<GroupRepresentation> groupMemberships(@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) { public Stream<GroupRepresentation> groupMemberships(@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
auth.require(AccountRoles.VIEW_GROUPS); auth.require(AccountRoles.VIEW_GROUPS);
return ModelToRepresentation.toGroupHierarchy(user, !briefRepresentation); return user.getGroupsStream().map(g -> ModelToRepresentation.toRepresentation(g, !briefRepresentation));
} }
@Path("/applications") @Path("/applications")

View File

@@ -16,6 +16,7 @@
*/ */
package org.keycloak.services.resources.admin; package org.keycloak.services.resources.admin;
import jakarta.ws.rs.DefaultValue;
import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension; import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; 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.ManagementPermissionReference;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponse;
import org.keycloak.services.Urls;
import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
@@ -59,6 +59,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.keycloak.utils.GroupUtils;
/** /**
* @resource Groups * @resource Groups
@@ -94,11 +95,11 @@ public class GroupResource {
public GroupRepresentation getGroup() { public GroupRepresentation getGroup() {
this.auth.groups().requireView(group); 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)); rep.setAccess(auth.groups().getAccess(group));
return rep; return GroupUtils.populateSubGroupCount(group, rep);
} }
/** /**
@@ -134,7 +135,7 @@ public class GroupResource {
private Stream<GroupModel> siblings() { private Stream<GroupModel> siblings() {
if (group.getParentId() == null) { if (group.getParentId() == null) {
return realm.getTopLevelGroupsStream(); return session.groups().getTopLevelGroupsStream(realm);
} else { } else {
return group.getParent().getSubGroupsStream(); return group.getParent().getSubGroupsStream();
} }
@@ -150,6 +151,21 @@ public class GroupResource {
adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success(); 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 * 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(); 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(); return builder.type(MediaType.APPLICATION_JSON_TYPE).entity(childRep).build();
} catch (ModelDuplicateException e) { } catch (ModelDuplicateException e) {
throw ErrorResponse.exists("Sibling group named '" + groupName + "' already exists."); throw ErrorResponse.exists("Sibling group named '" + groupName + "' already exists.");

View File

@@ -16,12 +16,26 @@
*/ */
package org.keycloak.services.resources.admin; 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.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension; import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.annotations.cache.NoCache;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.common.util.ObjectUtil; import org.keycloak.common.util.ObjectUtil;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType; 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.GroupUtils;
import org.keycloak.utils.SearchQueryUtils; 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 * @resource Groups
@@ -94,21 +94,25 @@ public class GroupsResource {
GroupPermissionEvaluator groupsEvaluator = auth.groups(); GroupPermissionEvaluator groupsEvaluator = auth.groups();
groupsEvaluator.requireList(); groupsEvaluator.requireList();
Stream<GroupModel> stream = null; Stream<GroupModel> stream;
if (Objects.nonNull(searchQuery)) { if (Objects.nonNull(searchQuery)) {
Map<String, String> attributes = SearchQueryUtils.getFields(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)) { } 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)) { } else if(Objects.nonNull(firstResult) && Objects.nonNull(maxResults)) {
stream = ModelToRepresentation.toGroupModelHierarchy(realm, !briefRepresentation, firstResult, maxResults); stream = session.groups().getTopLevelGroupsStream(realm, firstResult, maxResults);
} else { } else {
stream = realm.getTopLevelGroupsStream(); stream = session.groups().getTopLevelGroupsStream(realm);
} }
if(populateHierarchy) {
return GroupUtils.populateGroupHierarchyFromSubGroups(session, realm, stream, !briefRepresentation, groupsEvaluator);
}
boolean canViewGlobal = groupsEvaluator.canView(); boolean canViewGlobal = groupsEvaluator.canView();
return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group)) return stream
.map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact, !briefRepresentation, false)); .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.") @Operation( summary = "Returns the groups counts.")
public Map<String, Long> getGroupCount(@QueryParam("search") String search, public Map<String, Long> getGroupCount(@QueryParam("search") String search,
@QueryParam("top") @DefaultValue("false") boolean onlyTopGroups) { @QueryParam("top") @DefaultValue("false") boolean onlyTopGroups) {
GroupPermissionEvaluator groupsEvaluator = auth.groups();
groupsEvaluator.requireList();
Long results; Long results;
Map<String, Long> map = new HashMap<>(); Map<String, Long> map = new HashMap<>();
if (Objects.nonNull(search)) { 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.DatastoreProvider;
import org.keycloak.storage.ExportImportManager; import org.keycloak.storage.ExportImportManager;
import org.keycloak.storage.LegacyStoreSyncEvent; import org.keycloak.storage.LegacyStoreSyncEvent;
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.ProfileHelper; import org.keycloak.utils.ProfileHelper;
import org.keycloak.utils.ReservedCharValidator; import org.keycloak.utils.ReservedCharValidator;
@@ -1079,7 +1080,7 @@ public class RealmAdminResource {
} }
auth.groups().requireView(found); 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.UserProfile;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException;
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.ProfileHelper; import org.keycloak.utils.ProfileHelper;
import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.BadRequestException;
@@ -982,12 +983,7 @@ public class UserResource {
@QueryParam("max") Integer maxResults, @QueryParam("max") Integer maxResults,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) { @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
auth.users().requireView(user); auth.users().requireView(user);
return user.getGroupsStream(search, firstResult, maxResults).map(g -> ModelToRepresentation.toRepresentation(g, !briefRepresentation));
if (Objects.nonNull(search)) {
return ModelToRepresentation.searchForGroupByName(user, !briefRepresentation, search.trim(), firstResult, maxResults);
} else {
return ModelToRepresentation.toGroupHierarchy(user, !briefRepresentation, firstResult, maxResults);
}
} }
@GET @GET

View File

@@ -1,56 +1,88 @@
package org.keycloak.utils; package org.keycloak.utils;
import java.util.Comparator;
import java.util.Collections; import java.util.HashMap;
import java.util.stream.Collectors; import java.util.Map;
import java.util.Optional;
import org.keycloak.common.Profile; import java.util.stream.Stream;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator;
public class GroupUtils { 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); * 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
if (!lazy) { * {@link #toRepresentation} in order to keep database lookups separate from a function that aims to only convert objects
rep.setSubGroups(group.getSubGroupsStream().filter(g -> * A way of cohesively ensuring that a GroupRepresentation always has a group count should be considered
groupMatchesSearchOrIsPathElement( *
g, search * @param group model
) * @param representation group representation
).map(subGroup -> * @return
ModelToRepresentation.toGroupHierarchy( */
subGroup, full, search, exact public static GroupRepresentation populateSubGroupCount(GroupModel group, GroupRepresentation representation) {
) representation.setSubGroupCount(group.getSubGroupsCount());
return representation;
).collect(Collectors.toList()));
} else {
rep.setSubGroups(Collections.emptyList());
}
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) {
setAccess(groupsEvaluator, group, rep);
}
return rep;
} }
//From org.keycloak.admin.ui.rest.GroupsResource //From org.keycloak.admin.ui.rest.GroupsResource
// set fine-grained access for each group in the tree // set fine-grained access for each group in the tree
private static void setAccess(GroupPermissionEvaluator groupsEvaluator, GroupModel groupTree, GroupRepresentation rootGroup) { public static GroupRepresentation toRepresentation(GroupPermissionEvaluator groupsEvaluator, GroupModel groupTree, boolean full) {
if (rootGroup == null) return; GroupRepresentation rep = ModelToRepresentation.toRepresentation(groupTree, full);
rep.setAccess(groupsEvaluator.getAccess(groupTree));
rootGroup.setAccess(groupsEvaluator.getAccess(groupTree)); return rep;
rootGroup.getSubGroups().stream().forEach(subGroup -> {
GroupModel foundGroupModel = groupTree.getSubGroupsStream().filter(g -> g.getId().equals(subGroup.getId())).findFirst().get();
setAccess(groupsEvaluator, foundGroupModel, subGroup);
});
} }
private static boolean groupMatchesSearchOrIsPathElement(GroupModel group, String search) { 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.AuthorizationResource;
import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientScopeResource; 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.RealmResource;
import org.keycloak.admin.client.resource.RoleResource; import org.keycloak.admin.client.resource.RoleResource;
import org.keycloak.admin.client.resource.UserResource; 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; boolean contains = false;
for (GroupRepresentation sg : group.getSubGroups()) { for (GroupRepresentation sg : groupsResource.getSubGroups(null,null, true)) {
if (subgroup.getId().equals(sg.getId())) { if (subgroup.getId().equals(sg.getId())) {
contains = true; contains = true;
break; break;

View File

@@ -17,6 +17,8 @@
package org.keycloak.testsuite.admin.concurrency; package org.keycloak.testsuite.admin.concurrency;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientResource;
@@ -233,11 +235,18 @@ public class ConcurrencyTest extends AbstractConcurrencyTest {
c = realm.groups().group(id).toRepresentation(); c = realm.groups().group(id).toRepresentation();
assertNotNull(c); assertNotNull(c);
assertTrue("Group " + name + " [" + id + "] " + " not found in group list",
realm.groups().groups().stream() boolean retry = true;
.map(GroupRepresentation::getName) int i = 0;
.filter(Objects::nonNull) do {
.anyMatch(name::equals)); 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 group3;
GroupRepresentation parentGroup; GroupRepresentation parentGroup;
GroupRepresentation childGroup; GroupRepresentation childGroup;
GroupRepresentation secondChildGroup;
@Before @Before
public void init() { public void init() {
@@ -66,6 +67,7 @@ public class GroupSearchTest extends AbstractGroupTest {
group3 = new GroupRepresentation(); group3 = new GroupRepresentation();
parentGroup = new GroupRepresentation(); parentGroup = new GroupRepresentation();
childGroup = new GroupRepresentation(); childGroup = new GroupRepresentation();
secondChildGroup = new GroupRepresentation();
group1.setAttributes(new HashMap<>() {{ group1.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList(ATTR_ORG_VAL)); 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)); put(ATTR_QUOTES_NAME, Collections.singletonList(ATTR_QUOTES_VAL));
}}); }});
childGroup.setAttributes(new HashMap<>() {{ parentGroup.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList("parentOrg")); put(ATTR_ORG_NAME, Collections.singletonList("parentOrg"));
}}); }});
@@ -95,6 +97,7 @@ public class GroupSearchTest extends AbstractGroupTest {
group3.setName(GROUP3); group3.setName(GROUP3);
parentGroup.setName(PARENT_GROUP); parentGroup.setName(PARENT_GROUP);
childGroup.setName(CHILD_GROUP); childGroup.setName(CHILD_GROUP);
secondChildGroup.setName(CHILD_GROUP + "2");
} }
public RealmResource testRealmResource() { 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.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.updaters.Creator; import org.keycloak.testsuite.updaters.Creator;
import org.keycloak.testsuite.util.AdminEventPaths; import org.keycloak.testsuite.util.AdminEventPaths;
import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.ClientBuilder;
@@ -83,7 +82,6 @@ import org.keycloak.models.AdminRoles;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.testsuite.Assert.assertNames; import static org.keycloak.testsuite.Assert.assertNames;
@@ -426,15 +424,15 @@ public class GroupTest extends AbstractGroupTest {
topGroup = realm.getGroupByPath("/top"); topGroup = realm.getGroupByPath("/top");
assertEquals(1, topGroup.getRealmRoles().size()); assertEquals(1, topGroup.getRealmRoles().size());
assertTrue(topGroup.getRealmRoles().contains("topRole")); 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("level2", level2Group.getName());
assertEquals(1, level2Group.getRealmRoles().size()); assertEquals(1, level2Group.getRealmRoles().size());
assertTrue(level2Group.getRealmRoles().contains("level2Role")); 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("level3", level3Group.getName());
assertEquals(1, level3Group.getRealmRoles().size()); assertEquals(1, level3Group.getRealmRoles().size());
assertTrue(level3Group.getRealmRoles().contains("level3Role")); assertTrue(level3Group.getRealmRoles().contains("level3Role"));
@@ -559,10 +557,11 @@ public class GroupTest extends AbstractGroupTest {
response.close(); response.close();
// Assert "mygroup2" was moved // Assert "mygroup2" was moved
group1 = realm.groups().group(group1.getId()).toRepresentation(); List<GroupRepresentation> group1Children = realm.groups().group(group1.getId()).getSubGroups(0, 10, false);
group2 = realm.groups().group(group2.getId()).toRepresentation(); List<GroupRepresentation> group2Children = realm.groups().group(group2.getId()).getSubGroups(0, 10, false);
assertNames(group1.getSubGroups(), "mygroup2");
assertEquals("/mygroup1/mygroup2", group2.getPath()); assertNames(group1Children, "mygroup2");
assertEquals("/mygroup1/mygroup2", realm.groups().group(group2.getId()).toRepresentation().getPath());
assertAdminEvents.clear(); assertAdminEvents.clear();
@@ -583,10 +582,10 @@ public class GroupTest extends AbstractGroupTest {
response.close(); response.close();
// Assert "mygroup2" was moved // Assert "mygroup2" was moved
group1 = realm.groups().group(group1.getId()).toRepresentation(); group1Children = realm.groups().group(group1.getId()).getSubGroups(0, 10, false);
group2 = realm.groups().group(group2.getId()).toRepresentation(); group2Children = realm.groups().group(group2.getId()).getSubGroups(0, 10, false);
assertTrue(group1.getSubGroups().isEmpty()); assertEquals(0, group1Children.size());
assertEquals("/mygroup2", group2.getPath()); assertEquals("/mygroup2", realm.groups().group(group2.getId()).toRepresentation().getPath());
} }
@Test @Test
@@ -1160,7 +1159,19 @@ public class GroupTest extends AbstractGroupTest {
assertNotNull(group0); assertNotNull(group0);
assertEquals(2,group0.getSubGroups().size()); assertEquals(2,group0.getSubGroups().size());
assertThat(group0.getSubGroups().stream().map(GroupRepresentation::getName).collect(Collectors.toList()), Matchers.containsInAnyOrder("group1111", "group111111")); 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 @Test
@@ -1192,7 +1203,7 @@ public class GroupTest extends AbstractGroupTest {
Comparator<GroupRepresentation> compareByName = Comparator.comparing(GroupRepresentation::getName); Comparator<GroupRepresentation> compareByName = Comparator.comparing(GroupRepresentation::getName);
// Assert that all groups are returned in order // 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()); assertEquals(40, allGroups.size());
assertTrue(Comparators.isInStrictOrder(allGroups, compareByName)); 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.admin.client.resource.RealmResource;
import org.keycloak.authorization.client.AuthorizationDeniedException; import org.keycloak.authorization.client.AuthorizationDeniedException;
import org.keycloak.authorization.client.AuthzClient; import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.authorization.model.Resource;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
@@ -259,7 +260,7 @@ public class GroupNamePolicyTest extends AbstractAuthzTest {
continue; 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())) { if (path.endsWith(group.getName())) {
return group; return group;
@@ -272,12 +273,13 @@ public class GroupNamePolicyTest extends AbstractAuthzTest {
} }
private GroupRepresentation getGroup(String name, List<GroupRepresentation> groups) { private GroupRepresentation getGroup(String name, List<GroupRepresentation> groups) {
RealmResource realm = getRealm();
for (GroupRepresentation group : groups) { for (GroupRepresentation group : groups) {
if (name.equals(group.getName())) { if (name.equals(group.getName())) {
return group; 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())) { if (child != null && name.equals(child.getName())) {
return child; return child;

View File

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

View File

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