Add Groups resource type and scopes to authorization schema
keycloak/keycloak#37038Golden Comments (2/2 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | Incorrect permission check in canManage() method |
HIGH | ✓ Found | 🔴 "The no-arg canManage() includes AdminPermissionsSchema.VIEW in the permission check, so an admin holding only the view scope on all groups is granted manage access and can create/rename top-level groups (GroupsResource.addTopLevelGroup gates on requireManage()). This is a privilege escalation, inconsistent with the per-group canManage(GroupModel) override (MANAGE only) and with UserPermissionsV2.canManage()" (GroupPermissionsV2.java L76) |
| #2 | In getGroupIdsWithViewPermission, hasPermission is called with groupResource.getId() and the same groupResource.getId() is added to granted, but hasPermission resolves resources by name (treating the argument as a group id) and the GroupPermissionEvaluator contract says this method returns group IDs that are later used as UserModel.GROUPS and in getUsersCount group filters. This mismatch means per-group VIEW_MEMBERS/MANAGE_MEMBERS permissions may not yield the expected group IDs for filtering and counts, and evaluation may effectively only look at the type-level 'all-groups' resource. |
HIGH | ✓ Found | 🔴 "getGroupIdsWithViewPermission() is broken and breaks group-scoped user search/count (consumed by UsersResource.searchForUser/getUsersCount and BruteForceUsersResource.searchForUser in their DB group filter): (1) it adds groupResource.getId() (a resource UUID) instead of the group ID into the returned set, so downstream group-membership filtering matches nothing; (2) hasPermission(groupResource.getId(), ...) re-resolves by name using the resource UUID, which never matches group resources that are named by group ID, falling through to the all-groups fallback; (3) findByType only yields the all-groups resource because individual group resources are not typed. As a result group-scoped filters are ineffective or wrong (empty set or non-group UUIDs)" (GroupPermissionsV2.java L128) |
Supplementary Findings (11 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
The "all-users" fallback in hasPermission() only applies when the target user has no per-user resource. Once any admin creates a scope permission on a specific user, the all-users grant is never evaluated for that user — a false-negative access-control bug affecting canView/canManage/canMapRoles/canManageGroupMembership/canImpersonate |
UserPermissionsV2.java:104 | HIGH | ✓ Real: wrongly denies legitimate access to admins holding an all-users grant |
getResourceName (used to set policy display names) only special-cases USERS_RESOURCE_TYPE; the new Groups type falls through to resource.getDisplayName(), which is null — group scope-permission policies render a null/blank resource name |
AdminPermissionsSchema.java:331 | MEDIUM | ✓ Real: null display names in the admin console for group policies |
Legacy GroupPermissionManagement methods (isPermissionsEnabled, setPermissionsEnabled, viewPermission, managePermission, resource(group), getPermissions(group)) throw UnsupportedOperationException — GET/PUT /groups/{id}/management/permissions return HTTP 500 in V2-enabled realms |
GroupPermissionsV2.java:167 | MEDIUM | ✓ Real: reachable HTTP 500 on legacy management endpoints |
The two group-listing paths diverge under the V2 evaluator: hierarchy mode filters by g -> canView() || canView(g), while the flat path filters by canView(g) only — a caller with only an all-groups VIEW/MANAGE grant sees a group in hierarchy mode but it disappears in flat mode |
GroupsResource.java:113 | MEDIUM | ✓ Real: inconsistent visibility between listing modes |
getGroupCount is guarded only by requireList() and returns realm.getGroupsCount(...) for the whole realm without per-group view filtering, leaking the existence/count of groups the caller cannot view |
GroupsResource.java:157 | MEDIUM | ✓ Real information leak; pre-existing but made visible by the fine-grained group scopes |
registerListener is gated on the V1 flag while the factory methods dispatch on the V2 flag — with both features on, removing a group/client calls the throwing setPermissionsEnabled(...) methods; with only V2 on, orphaned V2 permission resources are never cleaned up |
AdminPermissions.java:101 | MEDIUM | ✓ Real: exceptions during removal and orphaned resources under V2 |
getGroupIdsWithViewPermission() javadoc documents "@return Stream of IDs..." but the method returns Set<String> — the ambiguity likely led the V2 implementation to return resource IDs |
GroupPermissionEvaluator.java:147 | LOW | ✓ Borderline: doc mismatch, but plausibly causal for golden #2 |
hasPermission() iterates permission.getScopes() without first checking permission.getResourceId().equals(resource.getId()), unlike UserPermissionsV2.hasPermission() — a scope match can leak from a different resource |
GroupPermissionsV2.java:136 | LOW | ✓ Real: missing guard, inconsistent with the user counterpart |
Stray double semicolon in the topGroup field declaration (new GroupRepresentation();;) |
GroupResourceTypeEvaluationTest.java:77 | LOW | ✓ Borderline: cosmetic only |
Whitespace-only line between createGroupPermission(topGroup, ...) and the following comment |
GroupResourceTypeEvaluationTest.java:272 | LOW | ✓ Borderline: cosmetic only |
In testManageAllGroups the created testGroup (and subGroup in testManageGroup) are not registered for cleanup with realm.cleanup().add(...), unlike topGroup/myGroup — only works because the realm is recreated per test method |
GroupResourceTypeEvaluationTest.java:165 | LOW | ✓ Real robustness issue against a per-class lifecycle change |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌤️ Feat |
|---|---|
| 3 high, 5 medium, 5 low | 1 mitigated |
Review of the MR adding the Groups resource type and scopes to the authorization schema. The schema definition itself — resource type name Groups, the five scope names (manage, view, manage-membership, manage-members, view-members), resource creation, and scope validation in AdminPermissionsSchema — is correct and consistent with the V1 GroupPermissions constants, the V2 GroupPermissionsV2 consumers, and the admin-console message keys. However, the V2 evaluation layer contains several blocking defects:
- Privilege escalation:
GroupPermissionsV2.canManage()(no-arg) returns true for aVIEW-only grant, allowing a view-only admin to create/rename top-level groups. The per-group overload and the user counterpart correctly require onlyMANAGE. - Broken group-membership filtering: the new
GroupPermissionsV2.getGroupIdsWithViewPermission()returns resource UUIDs instead of group IDs and never evaluates individual group resources, breaking group-scoped user search/count inUsersResourceandBruteForceUsersResourceunder V2. - False-negative access control: the
UserPermissionsV2.hasPermission()"all-users" fallback is shadowed as soon as an unrelated per-user resource exists, wrongly denying legitimate access to admins holding an all-users grant. - Functional regressions: legacy group management endpoints (
GET/PUT /groups/{id}/management/permissions) surfaceUnsupportedOperationException(HTTP 500) in V2; the event-listener cleanup gate is inconsistent with the V1/V2 dispatch, causing exceptions during group/client removal when both features are enabled and leaving orphaned resources when only V2 is on; and the hierarchy vs flat group-listing paths diverge in visibility under V2.
Also noted: getResourceName does not resolve the new Groups type (null policy display names), getGroupCount leaks realm-wide counts regardless of per-group view permission, and a few minor cleanup items (javadoc mismatch, missing resourceId guard, test cleanup). Status: REQUEST_CHANGES.
These might need a close look
- 🔴 services/.../admin/permissions/GroupPermissionsV2.java (L76)
The no-arg canManage() includes AdminPermissionsSchema.VIEW in the permission check, so an admin holding only the view scope on all groups is granted manage access and can create/rename top-level groups (GroupsResource.addTopLevelGroup gates on requireManage()). This is a privilege escalation, inconsistent with the per-group canManage(GroupModel) override (MANAGE only) and with UserPermissionsV2.canManage(). Remove VIEW so canManage() requires only MANAGE; also correct the GroupPermissionEvaluator.canManage() javadoc that documents VIEW-or-MANAGE. - 🔴 services/.../admin/permissions/GroupPermissionsV2.java (L128)
getGroupIdsWithViewPermission() is broken and breaks group-scoped user search/count (consumed by UsersResource.searchForUser/getUsersCount and BruteForceUsersResource.searchForUser in their DB group filter): (1) it adds groupResource.getId() (a resource UUID) instead of the group ID into the returned set, so downstream group-membership filtering matches nothing; (2) hasPermission(groupResource.getId(), ...) re-resolves by name using the resource UUID, which never matches group resources that are named by group ID, falling through to the all-groups fallback; (3) findByType only yields the all-groups resource because individual group resources are not typed. As a result group-scoped filters are ineffective or wrong (empty set or non-group UUIDs). Rework to evaluate each group resource directly and return/use groupResource.getName(), mirroring V1 GroupPermissions. - 🔴 services/.../admin/permissions/UserPermissionsV2.java (L104)
The "all-users" fallback in hasPermission() only applies when the target user has no per-user resource. Once any admin creates a scope permission on a specific user (materializing a per-user resource named after the user id), the all-users grant is never evaluated for that user. Admin B holding an all-users VIEW/MANAGE grant is then wrongly denied for a user that Admin A has a per-user resource on — a false-negative access-control bug affecting canView/canManage/canMapRoles/canManageGroupMembership/canImpersonate. The all-users resource should be evaluated in addition to (OR with) the per-user resource rather than only as a fallback. The same pattern exists in GroupPermissionsV2.hasPermission.
Worth checking
- 🟡 server-spi-private/.../AdminPermissionsSchema.java (L331)
getResourceName (used to set policy display names) only special-cases USERS_RESOURCE_TYPE. The new Groups resource type falls through to resource.getDisplayName(), which is null because group resources (individual and the all-groups resource) are created without a displayName. Consequently group scope-permission policies render a null/blank resource name. Add a GROUPS branch returning "All groups" for the type-level resource and resolving the group name for individual group resources (mirroring the USERS handling). - 🟡 services/.../admin/permissions/GroupPermissionsV2.java (L167)
Several legacy GroupPermissionManagement methods (isPermissionsEnabled, setPermissionsEnabled, viewPermission, managePermission, resource(group), getPermissions(group), etc.) throw UnsupportedOperationException. These are reachable via GroupResource.getManagementPermissions() / setManagementPermissionsEnabled(), so GET/PUT /groups/{id}/management/permissions return HTTP 500 in V2-enabled realms. Guard these endpoints (e.g., return 404/403 or an explicit unsupported response) instead of surfacing the exception. - 🟡 services/.../admin/GroupsResource.java (L113)
The two group-listing paths diverge under the V2 evaluator: hierarchy mode filters by g -> canView() || canView(g), while the flat path (populateHierarchy=false, this line) filters by canView(g) only. Under GroupPermissionsV2, canView() and canView(group) are evaluated independently, so a caller with only an all-groups VIEW/MANAGE grant sees a group in hierarchy mode but it disappears in flat mode. The same divergence exists in GroupResource.getSubGroups (filters by canView(group) only). Make all listing paths use the same predicate (g -> canView() || canView(g)) or make canView(group) honor the all-groups permission. - 🟡 services/.../admin/GroupsResource.java (L157)
getGroupCount is guarded only by requireList() and returns realm.getGroupsCount(...) for the whole realm without per-group view filtering, so a caller that can list but holds view permission for only a subset of groups still receives the total count, leaking the existence/count of groups it cannot view. Consider filtering the count by viewable groups (pre-existing, but made more visible by the fine-grained group scopes). - 🟡 services/.../admin/permissions/AdminPermissions.java (L101)
registerListener is gated on the V1 flag (ADMIN_FINE_GRAINED_AUTHZ) while the factory methods dispatch on the V2 flag (ADMIN_FINE_GRAINED_AUTHZ_V2). This produces two broken scenarios: (1) with both features on, removing a group/client calls GroupPermissionsV2/ClientPermissionsV2.setPermissionsEnabled(...) which throw UnsupportedOperationException, raising an exception during removal instead of cleaning up; (2) with only V2 on, the listener is never registered, so orphaned V2 permission resources are never cleaned up. Base the registration on the same condition as the factories and handle the V2 path by deleting orphaned resources instead of calling the throwing methods.
Small things (take or leave)
- 🔵 services/.../admin/permissions/GroupPermissionEvaluator.java (L147)
getGroupIdsWithViewPermission() javadoc documents "@return Stream of IDs of groups with view permission" but the method returns Set<String>. Update the doc to say Set and clarify the values are group IDs (the ambiguity likely led the V2 impl to return resource IDs). - 🔵 services/.../admin/permissions/GroupPermissionsV2.java (L136)
hasPermission() iterates permission.getScopes() without first checking permission.getResourceId().equals(resource.getId()), unlike UserPermissionsV2.hasPermission() which guards each permission by resource id. Add the same getResourceId() guard for consistency and to avoid a scope match leaking from a different resource in the returned Collection<Permission>. - 🔵 tests/.../fgap/GroupResourceTypeEvaluationTest.java (L77)
Stray double semicolon in the topGroup field declaration (new GroupRepresentation();;). Harmless but should be removed for cleanliness. - 🔵 tests/.../fgap/GroupResourceTypeEvaluationTest.java (L272)
Whitespace-only line between createGroupPermission(topGroup, ...) and the following //create new user comment. Remove the trailing whitespace to keep the file lint-clean. - 🔵 tests/.../fgap/GroupResourceTypeEvaluationTest.java (L165)
In testManageAllGroups the created testGroup (and subGroup in testManageGroup) are not registered for cleanup with realm.cleanup().add(...), unlike topGroup/myGroup. This only works because the realm is recreated per test method; registering the created groups would make the test robust against a per-class lifecycle change (which would break the exact-count assertion in testViewGroups).
✅ Feature-level checklist
Partially covered
- ⚠️ Add the Groups resource type and scopes to the authorization schema and evaluate permission checks for the new Groups resource type.
The schema definition (resource type name 'Groups', scope names, resource creation and scope validation) is correct and consistent with the V1/V2 consumers. However, the V2 permission evaluation for groups has multiple defects — a canManage() privilege escalation allowing view-only admins to manage top-level groups, a broken getGroupIdsWithViewPermission() that breaks group-scoped user search/count, the all-users fallback shadowing in user evaluation, and HTTP 500s on the management/permissions endpoints — so the evaluation part is not fully met and requires fixes before the feature behaves correctly.
FriendlyReviewer found both golden comments of this PR. On the
canManage()privilege escalation (L76), the review names the exact mechanism:VIEWis included in the no-argcanManage(), a view-only admin can create/rename top-level groups viaGroupsResource.addTopLevelGroup, and the inconsistency with the per-groupcanManage(GroupModel)overload andUserPermissionsV2.canManage()is spelled out. OngetGroupIdsWithViewPermission(L128), the coverage is deeper than the reference comment: the resource-UUID-instead-of-group-ID error, the name-based re-resolution that never matches, the all-groups fallback, and the downstream impact onUsersResource.searchForUser/getUsersCountandBruteForceUsersResourceare all documented.The supplementary set contains a third bug of golden caliber: the all-users fallback shadowing in
UserPermissionsV2.hasPermission(L104) — as soon as one per-user resource exists, an all-users grant is never evaluated for that user, wrongly denying admins. This is a false-negative access-control defect that the goldens do not cover, and the review flags the same pattern inGroupPermissionsV2.hasPermission. The five medium items (getResourceNamenull display names, HTTP 500 on the legacy/groups/{id}/management/permissionsendpoints, hierarchy-vs-flat divergence,getGroupCountleaking realm totals, the V1-only listener registration) are all reachable production issues; the five lows are mostly cosmetic (javadoc mismatch, missinggetResourceIdguard, double semicolon, whitespace, missing test cleanup).Honest caveats: the low items include pure nits (double semicolon, whitespace-only line) that inflate the finding count, and the
getGroupCountleak — a security-relevant disclosure of group existence — is only classified as medium. Conversely, the javadoc mismatch finding (GroupPermissionEvaluator L147) is genuinely insightful: the review connects the wrong documented return type to the V2 implementation's resource-UUID bug. No false positives stand out among the production findings; the severity mix (3 high, 5 medium, 5 low) matches what was posted.Overall verdict: full recall on the two most severe defects of the PR, one additional defect of the same class found beyond the goldens, and a defensible REQUEST_CHANGES. The review is at its strongest exactly where the goldens sit — V2 evaluator semantics — and its weak spots are confined to cosmetic lows.