Skip to content

Group hierarchy search discloses hidden parent groups under FGAP v2 - #50967

Merged
pedroigor merged 1 commit into
keycloak:mainfrom
vramik:cve-891
Jul 28, 2026
Merged

Group hierarchy search discloses hidden parent groups under FGAP v2#50967
pedroigor merged 1 commit into
keycloak:mainfrom
vramik:cve-891

Conversation

@vramik

@vramik vramik commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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.

Copilot AI review requested due to automatic review settings July 16, 2026 19:04
@vramik
vramik requested a review from a team as a code owner July 16, 2026 19:04
@vramik
vramik requested a review from martin-kanis July 16, 2026 19:04

Copilot AI left a comment

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.

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.

Comment thread services/src/main/java/org/keycloak/utils/GroupUtils.java
Copilot AI review requested due to automatic review settings July 16, 2026 19:22

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread services/src/main/java/org/keycloak/utils/GroupUtils.java
Copilot AI review requested due to automatic review settings July 16, 2026 19:36

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Copilot AI review requested due to automatic review settings July 16, 2026 20:25

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 21, 2026 09:01

Copilot AI left a comment

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.

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);

@keycloak-github-bot keycloak-github-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unreported flaky test detected, please review

@keycloak-github-bot

Copy link
Copy Markdown

Unreported flaky test detected

If 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

Keycloak CI - Base IT (5)

org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
	at org.junit.jupiter.api.AssertTrue.failNotTrue(AssertTrue.java:63)
	at org.junit.jupiter.api.AssertTrue.assertTrue(AssertTrue.java:36)
...

Report flaky test

@martin-kanis martin-kanis left a comment

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.

@vramik Thanks for the PR! Few points:

  1. 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
  2. Consider adding an entry to "Notable changes" in upgrading guide as we have there some similar entries for listing sessions

Copilot AI review requested due to automatic review settings July 22, 2026 21:47

@keycloak-github-bot keycloak-github-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unreported flaky test detected, please review

@keycloak-github-bot

Copy link
Copy Markdown

Unreported flaky test detected

If 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#testLoginPageRefresh

Keycloak CI - Forms IT (chrome)

org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
	at org.junit.jupiter.api.AssertTrue.failNotTrue(AssertTrue.java:63)
	at org.junit.jupiter.api.AssertTrue.assertTrue(AssertTrue.java:36)
...

Report flaky test

edewit
edewit previously approved these changes Jul 24, 2026
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) => (

Copilot AI review requested due to automatic review settings July 27, 2026 11:47

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Comment on lines +209 to +213
<span
className={
!canViewDetails && !isOrgGroups && !group.access?.view
? "keycloak-groups-tree__non-viewable"
: undefined
Copilot AI review requested due to automatic review settings July 27, 2026 12:14

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 28, 2026 09:57

Copilot AI left a comment

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.

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 groups includes 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 map segments onto 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/disabled flag 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., the LOADING_TREE entry), item.id! can still be navigated, producing an invalid id path and route. Add an explicit early return for the loading placeholder IDs (or, more robustly, for items that don’t have an underlying group attached) before calling findGroup(...) / 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;
    }

@pedroigor

Copy link
Copy Markdown
Contributor

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 view access:

admin/realms/fgap/groups/<parent-id>

The request happens before querying the subgroup data.

@pedroigor pedroigor left a comment

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.

API-wise, LGTM.

There is a behavior in the Admin UI I'm not sure it is expected. See #50967 (comment).

@pedroigor pedroigor left a comment

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.

Sorry, I was using a wrong version of the Admin UI. LGTM.

@pedroigor
pedroigor dismissed martin-kanis’s stale review July 28, 2026 14:48

Looks like it was already addressed.

Copilot AI review requested due to automatic review settings July 28, 2026 14:48

Copilot AI left a comment

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.

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

  • deriveSubGroupCountFromChildren only sets subGroupCount when it is null. If the stripped/brief representation ever comes with a non-null subGroupCount (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 overriding subGroupCount from group.getSubGroups().size() for non-viewable/stripped ancestors (e.g., based on group.getAccess() == null) when subGroupsCount is enabled, rather than only when subGroupCount is null.
        if (subGroupsCount) {
            groupIdToGroups.values().forEach(GroupUtils::deriveSubGroupCountFromChildren);
        }

services/src/main/java/org/keycloak/utils/GroupUtils.java:192

  • deriveSubGroupCountFromChildren only sets subGroupCount when it is null. If the stripped/brief representation ever comes with a non-null subGroupCount (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 overriding subGroupCount from group.getSubGroups().size() for non-viewable/stripped ancestors (e.g., based on group.getAccess() == null) when subGroupsCount is enabled, rather than only when subGroupCount is 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 a GroupRepresentation with subGroups populated. If the endpoint returns a brief representation (or doesn’t embed subgroups for search/list responses), parentGroup.getSubGroups() may be null, 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 via group(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 a GroupRepresentation with subGroups populated. If the endpoint returns a brief representation (or doesn’t embed subgroups for search/list responses), parentGroup.getSubGroups() may be null, 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 via group(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 group result is treated the same as a forbidden ancestor (placeholder { id, name: id }). If findOne can return undefined/null on 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, propagate notFound (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 throws notFound anyway. 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 to GroupsSection) instead of converting it to notFound.
          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;
    }

Copilot AI review requested due to automatic review settings July 28, 2026 15:06

Copilot AI left a comment

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.

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 around findOne that 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;
              }
            }

Comment on lines +92 to 106
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)
);
Comment on lines +185 to +192
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());
}
}
}
@pedroigor
pedroigor merged commit 55b56bc into keycloak:main Jul 28, 2026
91 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CVE-2026-15945] Group hierarchy search discloses hidden parent groups under FGAP v2

5 participants