Group hierarchy search discloses hidden parent groups under FGAP v2 - #50967
Group hierarchy search discloses hidden parent groups under FGAP v2#50967vramik wants to merge 1 commit into
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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
js/apps/admin-ui/src/groups/GroupsSection.tsx:116
- Group names may contain
/(seeGroupSearchTest.testGroupsWithSlashes), and group paths are ambiguous when slash escaping is disabled by default. Splitting the leaf path positionally can therefore label an ancestor incorrectly—for example,/parent/slash/childassignsparentinstead ofparent/slash; obtain ancestor names from group representations keyed by the route IDs rather than parsingpath.
const segments = lastGroup.path.split("/").filter(Boolean);
martin-kanis
left a comment
There was a problem hiding this comment.
Thanks for the update.
I would like to invite @ssilvert to have a look at UI as there is more changed lines now.
Besides the Copilot's comment you are looking at, there might be a problem with GroupPickerDialog when clicking a stripped parent row, user will get 403 error page.
The backend changes, tests and documentation looks good to me.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
js/apps/admin-ui/src/groups/components/GroupTree.tsx:351
- This also blocks navigation for organization groups because their representations do not contain
access; organization authorization is enforced separately byOrganizationGroupsResource. IncludeisOrgGroupsin the allow condition, asGroupTable.tsx:212already does for the same reason.
const path = findGroup(data, item.id!, []);
if (!(canViewDetails || path.at(-1)?.access?.view)) {
return;
ccfbcdf to
3b0a90a
Compare
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 |
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 (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;
}
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.