Group hierarchy search discloses hidden parent groups under FGAP v2 - #50967
Conversation
There was a problem hiding this comment.
Pull request overview
Prevents FGAP v2 group searches from exposing protected parent-group content while preserving hierarchy structure.
Changes:
- Returns brief representations for non-viewable ancestors.
- Adds an integration test for redacted parent data.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
services/src/main/java/org/keycloak/utils/GroupUtils.java |
Applies permission-aware hierarchy redaction. |
tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeFilteringTest.java |
Tests stripped hidden-parent responses. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeFilteringTest.java:102
- This parent is top-level, so the regression test only exercises one hierarchy iteration and does not verify the stated requirement that traversal continues through multiple non-viewable ancestors. Add a grandparent (or deeper chain) and assert that every hidden ancestor is retained as a stripped representation around the visible descendant.
GroupRepresentation parentGroup = realm.admin().groups().groups("group-0", -1, -1).get(0);
Unreported flaky test detectedIf the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR. org.keycloak.testsuite.forms.BrowserFlowTest#testUserWithOneAdditionalFactorOtpSuccess |
martin-kanis
left a comment
There was a problem hiding this comment.
@vramik Thanks for the PR! Few points:
- There might be UI regression when an admin has VIEW on a child group but not on its parent and searches for the child group - only parent is rendered as a leaf node and the child group admin searched for is invisible
- Consider adding an entry to "Notable changes" in upgrading guide as we have there some similar entries for listing sessions
Unreported flaky test detectedIf the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR. org.keycloak.testsuite.forms.MultipleTabsLoginTest#testLoginPageRefreshKeycloak CI - Forms IT (chrome) |
| error instanceof NetworkError && | ||
| error.response.status === 403 | ||
| ) { | ||
| group = undefined; |
There was a problem hiding this comment.
if there is an error wouldn't group still be undefined?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) => (
| <span | ||
| className={ | ||
| !canViewDetails && !isOrgGroups && !group.access?.view | ||
| ? "keycloak-groups-tree__non-viewable" | ||
| : undefined |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
services/src/main/java/org/keycloak/utils/GroupUtils.java:134
groupIdToGroups.values().forEach(...)will re-traverse the same subtrees many times (once per node) because each call recursively walks descendants. This can become O(n²) for larger hierarchies. A more scalable approach is to derive counts starting only from the root nodes (the same set you ultimately return), or to add a visited/memoization set so each node’s count is derived at most once.
if (subGroupsCount) {
groupIdToGroups.values().forEach(GroupUtils::deriveSubGroupCountFromChildren);
}
services/src/main/java/org/keycloak/utils/GroupUtils.java:192
groupIdToGroups.values().forEach(...)will re-traverse the same subtrees many times (once per node) because each call recursively walks descendants. This can become O(n²) for larger hierarchies. A more scalable approach is to derive counts starting only from the root nodes (the same set you ultimately return), or to add a visited/memoization set so each node’s count is derived at most once.
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());
}
}
}
js/apps/admin-ui/src/groups/GroupsSection.tsx:139
- This positional mapping breaks when
groupsincludes non-hierarchy entries like the"search"breadcrumb (which is explicitly pushed earlier). In that case,segments[idx]no longer corresponds to the same logical group index, so hidden ancestors can be assigned the wrong name (off-by-one). Consider deriving an index mapping that skips"search"(and any other non-group sentinel IDs), or build a separate array of “group crumbs” and mapsegmentsonto that instead.
// 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] };
}
}
}
js/apps/admin-ui/src/groups/components/GroupTree.tsx:217
- Styling non-viewable nodes as greyed out is helpful, but these nodes can still appear interactive to keyboard/screen-reader users unless they are programmatically marked as disabled. PatternFly TreeView items support disabled semantics (e.g., an
isDisabled/disabledflag on the item model, depending on the component API). Consider setting the disabled flag for non-viewable items so they’re removed from the interactive navigation flow and correctly announced as disabled.
<span
className={
!canViewDetails && !isOrgGroups && !group.access?.view
? "keycloak-groups-tree__non-viewable"
: undefined
}
>
{group.name}
</span>
js/apps/admin-ui/src/groups/components/GroupTree.tsx:352
- The guard only excludes the
"next"pagination item. If the tree includes non-navigable placeholder nodes (e.g., theLOADING_TREEentry),item.id!can still be navigated, producing an invalididpath and route. Add an explicit early return for the loading placeholder IDs (or, more robustly, for items that don’t have an underlyinggroupattached) before callingfindGroup(...)/navigate(...).
const nav = (item: TreeViewDataItem, data: ExtendedTreeViewDataItem[]) => {
if (item.id === "next") return;
const path = findGroup(data, item.id!, []);
if (!(canViewDetails || isOrgGroups || path.at(-1)?.access?.view)) {
return;
}
|
There is one issue with the Admin UI where clicking on a subgroup will redirect the user to a 403 page because the UI tries to query the parent group to which the user has no The request happens before querying the subgroup data. |
pedroigor
left a comment
There was a problem hiding this comment.
API-wise, LGTM.
There is a behavior in the Admin UI I'm not sure it is expected. See #50967 (comment).
pedroigor
left a comment
There was a problem hiding this comment.
Sorry, I was using a wrong version of the Admin UI. LGTM.
Looks like it was already addressed.
Closes keycloak#50966 Signed-off-by: vramik <vramik@redhat.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (8)
services/src/main/java/org/keycloak/utils/GroupUtils.java:134
deriveSubGroupCountFromChildrenonly setssubGroupCountwhen it isnull. If the stripped/brief representation ever comes with a non-nullsubGroupCount(e.g., set by another mapper or a future change), the response could leak the real database count for non-viewable ancestors—contradicting the stated goal and docs. Consider always overridingsubGroupCountfromgroup.getSubGroups().size()for non-viewable/stripped ancestors (e.g., based ongroup.getAccess() == null) whensubGroupsCountis enabled, rather than only whensubGroupCountis null.
if (subGroupsCount) {
groupIdToGroups.values().forEach(GroupUtils::deriveSubGroupCountFromChildren);
}
services/src/main/java/org/keycloak/utils/GroupUtils.java:192
deriveSubGroupCountFromChildrenonly setssubGroupCountwhen it isnull. If the stripped/brief representation ever comes with a non-nullsubGroupCount(e.g., set by another mapper or a future change), the response could leak the real database count for non-viewable ancestors—contradicting the stated goal and docs. Consider always overridingsubGroupCountfromgroup.getSubGroups().size()for non-viewable/stripped ancestors (e.g., based ongroup.getAccess() == null) whensubGroupsCountis enabled, rather than only whensubGroupCountis null.
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());
}
}
}
tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeFilteringTest.java:106
- This test assumes
groups("group-0", ...)returns aGroupRepresentationwithsubGroupspopulated. If the endpoint returns a brief representation (or doesn’t embed subgroups for search/list responses),parentGroup.getSubGroups()may benull, causing a flaky NPE. A more robust approach is to resolve the subgroup via a dedicated call that guarantees subgroups are present (e.g., fetch the parent viagroup(parentId).toRepresentation()and/or list subgroups via the subgroups endpoint) before streaming.
GroupRepresentation parentGroup = realm.admin().groups().groups("group-0", -1, -1).get(0);
tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeFilteringTest.java:114
- This test assumes
groups("group-0", ...)returns aGroupRepresentationwithsubGroupspopulated. If the endpoint returns a brief representation (or doesn’t embed subgroups for search/list responses),parentGroup.getSubGroups()may benull, causing a flaky NPE. A more robust approach is to resolve the subgroup via a dedicated call that guarantees subgroups are present (e.g., fetch the parent viagroup(parentId).toRepresentation()and/or list subgroups via the subgroups endpoint) before streaming.
GroupRepresentation visibleSubGroup = parentGroup.getSubGroups().stream()
.filter(group -> group.getName().equals("subgroup-0.1"))
.findFirst()
.orElseThrow();
js/apps/admin-ui/src/groups/GroupsSection.tsx:124
- For intermediate ancestor IDs, a falsy
groupresult is treated the same as a forbidden ancestor (placeholder{ id, name: id }). IffindOnecan returnundefined/nullon 404 (or other non-403 cases), this would mask “group not found” and build a misleading breadcrumb/navigation state. Consider only using the placeholder behavior for explicit 403 responses; for “not found” results, propagatenotFound(or throw) to avoid silently constructing a hierarchy for missing resources.
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;
}
}
js/apps/admin-ui/src/components/group/GroupPickerDialog.tsx:111
- The new 403 handling sets
group = undefined, but the very next check throwsnotFoundanyway. That means a forbidden (non-viewable) group will be surfaced as “not found”, which is semantically incorrect and can break workflows where the user is allowed to browse/search permitted descendants but not the current ancestor. Consider handling 403 differently (e.g., reset to root/search state, or create a minimal placeholder group similar toGroupsSection) instead of converting it tonotFound.
try {
group = await groupResource.findOne({ id: groupId });
} catch (error) {
if (
error instanceof NetworkError &&
error.response.status === 403
) {
group = undefined;
} else {
throw error;
}
}
if (!group) {
throw new Error(t("notFound"));
}
js/apps/admin-ui/src/groups/components/GroupTree.tsx:217
- Non-viewable ancestors are visually “greyed out”, but the TreeView item remains interactive/focusable and clicking it results in a no-op return (no navigation and no announcement). This can be confusing for keyboard and assistive-technology users, and the state is currently indicated primarily via color. Consider marking non-viewable items as disabled at the TreeView item level (or adding
aria-disabled/ preventing pointer events) so the “not clickable” state is conveyed programmatically and interaction is blocked consistently.
<span
className={
!canViewDetails && !isOrgGroups && !group.access?.view
? "keycloak-groups-tree__non-viewable"
: undefined
}
>
{group.name}
</span>
js/apps/admin-ui/src/groups/components/GroupTree.tsx:352
- Non-viewable ancestors are visually “greyed out”, but the TreeView item remains interactive/focusable and clicking it results in a no-op return (no navigation and no announcement). This can be confusing for keyboard and assistive-technology users, and the state is currently indicated primarily via color. Consider marking non-viewable items as disabled at the TreeView item level (or adding
aria-disabled/ preventing pointer events) so the “not clickable” state is conveyed programmatically and interaction is blocked consistently.
if (!(canViewDetails || isOrgGroups || path.at(-1)?.access?.view)) {
return;
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
js/apps/admin-ui/src/groups/components/GroupTree.tsx:217
- The “non-viewable” state is currently conveyed primarily via styling (grey text + cursor), but the item remains a selectable TreeView node and the
<span>has no semantic indication for assistive tech. Recommendation (moderate): add an explicit accessibility signal (e.g.,aria-disabled="true"and/or a visually hidden label like “(not viewable)”), and where feasible disable selection at the TreeView item level so keyboard users get consistent behavior (not just a no-op click handler).
<span
className={
!canViewDetails && !isOrgGroups && !group.access?.view
? "keycloak-groups-tree__non-viewable"
: undefined
}
>
{group.name}
</span>
js/apps/admin-ui/src/groups/GroupsSection.tsx:124
- The “treat 403 as hidden ancestor” logic is duplicated here (and similarly in
GroupPickerDialog.tsx). Recommendation (optional): extract a small shared helper (e.g.,isForbiddenNetworkError(error): boolean) or a wrapper aroundfindOnethat returns a placeholder on 403, to reduce repetition and keep error-handling behavior consistent across screens.
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;
}
}
| boolean canViewParent = filter.shouldInclude(parentModel); | ||
|
|
||
| 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) | ||
| ); |
| 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()); | ||
| } | ||
| } | ||
| } |
Closes #50966
Design decision: Align with industry standard — parent existence (name, ID, path) is inherent to the child's identity and not hidden. Only parent content (attributes, role mappings, access) is protected. Non-viewable parents are included in the hierarchy as stripped/brief representations.
Approach: when populating group hierarchy under FGAP v2, non-viewable parent groups are represented with only structural fields (id, name, description, path, parentId) — no attributes, role mappings, or access map. The hierarchy walk continues through stripped parents so the full tree structure is preserved.