Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ set of management operations:
| *manage-membership-of-members* | Defines if a realm administrator can grant or deny `manage-group-membership` for members of a group.
|===

When a delegated administrator has `view` permission on a group but not on its ancestor groups, the Admin REST API returns the full group hierarchy so the administrator can understand where the group belongs. Ancestor groups without `view` permission are returned as stripped representations containing only `id`, `name`, `path`, `parentId`, and `description`. Sensitive fields such as `attributes`, `realmRoles`, `clientRoles`, and `access` are omitted from these ancestors.

The `subGroupCount` field on stripped ancestors reflects the number of visible children included in the response rather than the total child count. In the admin console, non-viewable ancestor groups appear greyed out and are not clickable.

NOTE: When a delegated administrator has `view` permission only on groups that are not at the top level of the hierarchy, the admin console Groups page initially appears empty because only top-level groups are loaded on the initial page load. The administrator must use the search field to find their permitted groups. The search results then display the full hierarchy with stripped ancestors as described above.

===== Clients Resource Type

The *Clients* realm resource type represents the clients in a realm. You can manage permissions for clients based on the following
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ If you have delegated administrators that assign client scopes using Fine-Graine
// ------------------------ Notable changes ------------------------ //
== Notable changes

=== Group hierarchy search returns stripped ancestors under Fine-Grained Admin Permissions

When Fine-Grained Admin Permissions is enabled and a delegated administrator has `view` permission on a group but not on its ancestors, the Admin REST API now returns the full group hierarchy with non-viewable ancestor groups represented as stripped representations. Stripped ancestor representations contain only `id`, `name`, `path`, `parentId`, and `description` — sensitive fields such as `attributes`, `realmRoles`, `clientRoles`, and `access` are omitted.

The `subGroupCount` field on stripped ancestors reflects the number of visible children included in the response, not the total number of children in the database. This prevents leaking information about groups the administrator cannot access.

In the admin console, non-viewable ancestor groups now appear greyed out and are not clickable in the group tree and breadcrumb navigation. Clicking a viewable child group navigates to its details as expected. Note that when a delegated administrator has `view` permission only on groups that are not at the top level, the Groups page initially appears empty. The administrator must use the search field to locate their permitted groups.

=== AuthZEN changes

When calling {project_name}'s experimental AuthZEN endpoints, the caller now needs to be an authenticated confidential client's access token. Previously, it could be called with a user's access token.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { Link, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";

import { useAccess } from "../../context/access/Access";
import { useSubGroups } from "../../groups/SubGroupsContext";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useGroupResource } from "../../context/group-resource/GroupResourceContext";

import "../../groups/components/group-tree.css";

export const GroupBreadCrumbs = () => {
const { t } = useTranslation();
const { clear, remove, subGroups } = useSubGroups();
const { realm } = useRealm();
const { hasAccess } = useAccess();
const location = useLocation();
const canViewDetails =
hasAccess("query-groups", "view-users") ||
hasAccess("manage-users", "query-groups");

const isOrgGroups = useGroupResource().isOrgGroups();
const orgId = useGroupResource().getOrgId();
Expand All @@ -34,9 +41,10 @@ export const GroupBreadCrumbs = () => {
</BreadcrumbItem>
{subGroups.map((group, i) => {
const isLastGroup = i === subGroups.length - 1;
const canView = isOrgGroups || canViewDetails || group.access?.view;
return (
<BreadcrumbItem key={group.id} isActive={isLastGroup}>
{!isLastGroup && (
{!isLastGroup && canView && (
<Link
to={location.pathname.substring(
0,
Expand All @@ -47,6 +55,11 @@ export const GroupBreadCrumbs = () => {
{group.name}
</Link>
)}
{!isLastGroup && !canView && (
<span className="keycloak-groups-tree__non-viewable">
{group.name}
</span>
)}
{isLastGroup &&
(group.id === "search" ? group.name : t("groupDetails"))}
</BreadcrumbItem>
Expand Down
16 changes: 15 additions & 1 deletion js/apps/admin-ui/src/components/group/GroupPickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ModalVariant,
} from "@patternfly/react-core";
import { AngleRightIcon } from "@patternfly/react-icons";
import { NetworkError } from "@keycloak/keycloak-admin-client/lib/utils/fetchWithError";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../admin-client";
Expand Down Expand Up @@ -58,6 +59,7 @@ export const GroupPickerDialog = ({
}: GroupPickerDialogProps) => {
const { adminClient } = useAdminClient();
const groupResource = useGroupResource();
const isOrgGroups = groupResource.isOrgGroups();

const { t } = useTranslation();
const [selectedRows, setSelectedRows] = useState<SelectableGroup[]>([]);
Expand Down Expand Up @@ -92,7 +94,18 @@ export const GroupPickerDialog = ({
groups = await groupResource.find(args);
} else {
if (!navigation.map(({ id }) => id).includes(groupId)) {
group = await groupResource.findOne({ id: groupId });
try {
group = await groupResource.findOne({ id: groupId });
} catch (error) {
if (
error instanceof NetworkError &&
error.response.status === 403
) {
group = undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there is an error wouldn't group still be undefined?

@vramik vramik Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, group would still be undefined. The point of the catch is not to set the value — it's to prevent the 403 NetworkError from propagating to the error boundary, which would crash the entire page. Without the catch, useFetch passes the error to showBoundary() and the page is replaced with "You don't have access to the admin console." The catch swallows the 403 so execution continues to the if (!group) check, which shows a friendly "not found" message inside the dialog.

I am more than open to suggestions for improvemnt here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I think, if we go with this approach then if (!group) throw new Error(t("notFound")) will throw then useFetch catches it and showBoundary(error) replaces the whole page.
Cannot we filter stripped parents out of the flattened list?

In GroupPickerDialog.tsx
// line 61
const groupResource = useGroupResource();
const isOrgGroups = groupResource.isOrgGroups();

// lines 258-260
.map((g) => deepGroup([g]))
.flat()
.filter((g) => isOrgGroups || g.access)
.map((g) => (

} else {
throw error;
}
}
if (!group) {
throw new Error(t("notFound"));
}
Expand Down Expand Up @@ -245,6 +258,7 @@ export const GroupPickerDialog = ({
: groups
.map((g) => deepGroup([g]))
.flat()
.filter((g) => isOrgGroups || g.access)
.map((g) => (
<GroupRow
key={g.id}
Expand Down
49 changes: 41 additions & 8 deletions js/apps/admin-ui/src/groups/GroupsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { NetworkError } from "@keycloak/keycloak-admin-client/lib/utils/fetchWithError";
import { useFetch } from "@keycloak/keycloak-ui-shared";
import {
Button,
Expand Down Expand Up @@ -92,19 +93,51 @@ export default function GroupsSection({ orgId }: { orgId?: string } = {}) {

if (isNavigationStateInValid) {
const groups: GroupRepresentation[] = [];
const lastId = ids!.at(-1);
for (const i of ids!) {
let group = undefined;
if (i !== "search") {
group = await groupResource.findOne({ id: i });
if (i === "search") {
groups.push({ name: t("searchGroups"), id: "search" });
} else if (i === lastId) {
const group = await groupResource.findOne({ id: i });
if (group) {
groups.push(group);
} else {
throw new Error(t("notFound"));
}
} else {
group = { name: t("searchGroups"), id: "search" };
try {
const group = await groupResource.findOne({ id: i });
if (group) {
groups.push(group);
} else {
groups.push({ id: i, name: i, access: {} });
}
} catch (error) {
if (
error instanceof NetworkError &&
error.response.status === 403
) {
groups.push({ id: i, name: i, access: {} });
} else {
throw error;
}
}
}
if (group) {
groups.push(group);
} else {
throw new Error(t("notFound"));
}

// Derive ancestor names from the last group's path.
// Path is like "/grandparent/parent/child" — segments map
// positionally to the groups array.
const lastGroup = groups.at(-1);
if (lastGroup?.path) {
const segments = lastGroup.path.split("/").filter(Boolean);
for (let idx = 0; idx < groups.length - 1; idx++) {
if (groups[idx].id === groups[idx].name && segments[idx]) {
groups[idx] = { ...groups[idx], name: segments[idx] };
Comment thread
vramik marked this conversation as resolved.
}
}
}

return groups;
}
return [];
Expand Down
2 changes: 1 addition & 1 deletion js/apps/admin-ui/src/groups/Members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const Members = () => {
useFetch(() => groups.findOne({ id: group()!.id! }), setCurrentGroup, []);

const isManager =
hasAccess("manage-users") || currentGroup?.access!.manageMembership;
hasAccess("manage-users") || currentGroup?.access?.manageMembership;

const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
Expand Down
61 changes: 34 additions & 27 deletions js/apps/admin-ui/src/groups/components/GroupTree.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import {
AlertVariant,
Button,
Checkbox,
Divider,
Expand All @@ -16,11 +15,7 @@ import {
TreeViewDataItem,
} from "@patternfly/react-core";

import {
PaginatingTableToolbar,
useAlerts,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import { PaginatingTableToolbar, useFetch } from "@keycloak/keycloak-ui-shared";
import { AngleRightIcon, EllipsisVIcon } from "@patternfly/react-icons";
import { useGroupResource } from "../../context/group-resource/GroupResourceContext";
import { unionBy } from "lodash-es";
Expand All @@ -43,6 +38,7 @@ import "./group-tree.css";

type ExtendedTreeViewDataItem = TreeViewDataItem & {
access?: Record<string, boolean>;
group?: GroupRepresentation;
};

type GroupTreeContextMenuProps = {
Expand Down Expand Up @@ -178,11 +174,10 @@ export const GroupTree = ({
const { t } = useTranslation();
const { realm } = useRealm();
const navigate = useNavigate();
const { addAlert } = useAlerts();
const { hasAccess } = useAccess();

const [data, setData] = useState<ExtendedTreeViewDataItem[]>();
const { subGroups, clear } = useSubGroups();
const { subGroups, setSubGroups, clear } = useSubGroups();

const [search, setSearch] = useState("");
const [max, setMax] = useState(20);
Expand Down Expand Up @@ -211,12 +206,22 @@ export const GroupTree = ({
id: group.id,
name: (
<Tooltip content={group.name}>
<span>{group.name}</span>
<span
className={
!canViewDetails && !isOrgGroups && !group.access?.view
? "keycloak-groups-tree__non-viewable"
: undefined
Comment on lines +209 to +213
}
>
{group.name}
</span>
</Tooltip>
),
group,
access: group.access || {},
children: hasSubGroups
? search.length === 0
? search.length === 0 &&
(!group.subGroups || group.subGroups.length === 0)
? LOADING_TREE
: group.subGroups?.map((g) => mapGroup(g, refresh))
: undefined,
Expand Down Expand Up @@ -340,26 +345,28 @@ export const GroupTree = ({

const nav = (item: TreeViewDataItem, data: ExtendedTreeViewDataItem[]) => {
if (item.id === "next") return;
setActiveItem(item);

const path = findGroup(data, item.id!, []);
if (!subGroups.every(({ id }) => path.find((t) => t.id === id))) clear();
if (
canViewDetails ||
path.at(-1)?.access?.view ||
subGroups.at(-1)?.access?.view
) {
navigate(
toGroups({
realm,
id: path.map((g) => g.id).join("/"),
orgId,
}),
);
} else {
addAlert(t("noViewRights"), AlertVariant.warning);
navigate(toGroups({ realm, orgId }));
if (!(canViewDetails || isOrgGroups || path.at(-1)?.access?.view)) {
return;
}

setActiveItem(item);
const groups = path
.map((p) => p.group)
.filter((g): g is GroupRepresentation => g !== undefined);
if (groups.length === path.length) {
setSubGroups(groups);
} else if (!subGroups.every(({ id }) => path.find((t) => t.id === id))) {
clear();
}
navigate(
toGroups({
realm,
id: path.map((g) => g.id).join("/"),
orgId,
}),
);
};

return data ? (
Expand Down
5 changes: 5 additions & 0 deletions js/apps/admin-ui/src/groups/components/group-tree.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@
overflow: hidden;
text-overflow: ellipsis;
display: block;
}

.keycloak-groups-tree__non-viewable {
color: var(--pf-v5-global--Color--200);
cursor: default;
}
39 changes: 25 additions & 14 deletions services/src/main/java/org/keycloak/utils/GroupUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,23 @@ private static Stream<GroupRepresentation> buildGroupHierarchy(
while (currGroup.getParentId() != null && !currGroup.getParentId().equals(stopAtParentId)) {
GroupModel parentModel = session.groups().getGroupById(realm, currGroup.getParentId());

// Permission check for parent
if (!filter.shouldInclude(parentModel)) {
groupIdToGroups.remove(currGroup.getId());
break;
boolean canViewParent = filter.shouldInclude(parentModel);
Comment thread
vramik marked this conversation as resolved.

if (!canViewParent) {
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
groupIdToGroups.remove(currGroup.getId());
break;
}
}

GroupRepresentation parent = groupIdToGroups.computeIfAbsent(
currGroup.getParentId(),
id -> mapper.apply(parentModel)
id -> canViewParent ?
mapper.apply(parentModel) :
ModelToRepresentation.groupToBriefRepresentation(parentModel)
Comment thread
vramik marked this conversation as resolved.
);
Comment on lines +92 to 106

if (subGroupsCount) {
if (subGroupsCount && canViewParent) {
populateSubGroupCount(parentModel, parent);
}

Expand All @@ -124,6 +129,10 @@ private static Stream<GroupRepresentation> buildGroupHierarchy(
}
});

if (subGroupsCount) {
groupIdToGroups.values().forEach(GroupUtils::deriveSubGroupCountFromChildren);
}

return groupIdToGroups.values().stream()
.sorted(Comparator.comparing(GroupRepresentation::getName));
}
Expand All @@ -144,14 +153,7 @@ public static Stream<GroupRepresentation> populateGroupHierarchyFromSubGroups(Ke
groups,
// Mapper with permission-aware representation
group -> toRepresentation(groupEvaluator, group, full),
// Filter with permission checks
group -> {
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
return true; // FGAP v2 handles permissions differently
}
//TODO GROUPS do permissions work in such a way that if you can view the children you can definitely view the parents?
return groupEvaluator.canView() || groupEvaluator.canView(group);
},
groupEvaluator::canView,
subGroupsCount
);
}
Expand Down Expand Up @@ -180,6 +182,15 @@ public static Stream<GroupRepresentation> populateGroupHierarchyFromSubGroups(Ke
);
}

private static void deriveSubGroupCountFromChildren(GroupRepresentation group) {
if (group.getSubGroups() != null) {
group.getSubGroups().forEach(GroupUtils::deriveSubGroupCountFromChildren);
if (group.getSubGroupCount() == null && !group.getSubGroups().isEmpty()) {
group.setSubGroupCount((long) group.getSubGroups().size());
}
}
}
Comment on lines +185 to +192

/**
* This method's purpose is to look up the subgroup count of a Group and populate it on the representation. This has been kept separate from
* {@link #toRepresentation} in order to keep database lookups separate from a function that aims to only convert objects
Expand Down
Loading
Loading