From 4414ad2d881c61e9a4bb1fbc1d0a8def5a04ad5e Mon Sep 17 00:00:00 2001 From: vramik Date: Fri, 10 Jul 2026 12:04:11 +0200 Subject: [PATCH] Fine-Grained Admin Permissions Bypass via Role Groups Endpoint Closes #50616 Signed-off-by: vramik --- .../keycloak/models/jpa/JpaRealmProvider.java | 30 ++++++-- .../jpa/entities/GroupRoleMappingEntity.java | 1 - .../admin/RoleContainerResource.java | 2 + .../ClientResourceTypeEvaluationTest.java | 69 +++++++++++++++++++ 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java index 8105dbe51e72..bec0ae2253fa 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java @@ -40,6 +40,7 @@ import jakarta.persistence.criteria.MapJoin; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.client.clienttype.ClientTypeManager; @@ -75,6 +76,7 @@ import org.keycloak.models.jpa.entities.ClientScopeEntity; import org.keycloak.models.jpa.entities.GroupAttributeEntity; import org.keycloak.models.jpa.entities.GroupEntity; +import org.keycloak.models.jpa.entities.GroupRoleMappingEntity; import org.keycloak.models.jpa.entities.RealmEntity; import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity; import org.keycloak.models.jpa.entities.RoleEntity; @@ -750,14 +752,30 @@ public Long getGroupsCountByNameContaining(RealmModel realm, String search) { @Override public Stream getGroupsByRoleStream(RealmModel realm, RoleModel role, Integer firstResult, Integer maxResults) { - TypedQuery query = em.createNamedQuery("groupsInRole", GroupEntity.class); - query.setParameter("roleId", role.getId()); + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery queryBuilder = builder.createQuery(String.class); + Root root = queryBuilder.from(GroupEntity.class); + + queryBuilder.select(root.get("id")); + + Subquery roleMappingSubquery = queryBuilder.subquery(String.class); + Root roleMappingRoot = roleMappingSubquery.from(GroupRoleMappingEntity.class); + roleMappingSubquery.select(roleMappingRoot.get("group").get("id")); + roleMappingSubquery.where(builder.equal(roleMappingRoot.get("roleId"), role.getId())); + + List predicates = new ArrayList<>(); - Stream results = paginateQuery(query, firstResult, maxResults).getResultStream(); + predicates.add(root.get("id").in(roleMappingSubquery)); + predicates.add(builder.equal(root.get("realm"), realm.getId())); + predicates.add(builder.equal(root.get("type"), Type.REALM.intValue())); + predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root)); + + queryBuilder.where(predicates.toArray(new Predicate[0])); + queryBuilder.orderBy(builder.asc(root.get("name"))); - return closing(results - .map(g -> (GroupModel) new GroupAdapter(session, realm, em, g)) - .sorted(GroupModel.COMPARE_BY_NAME)); + return closing(paginateQuery(em.createQuery(queryBuilder), firstResult, maxResults).getResultStream() + .map(g -> session.groups().getGroupById(realm, g)) + .filter(Objects::nonNull)); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/GroupRoleMappingEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/GroupRoleMappingEntity.java index 53b2db509caf..7ca15c992e24 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/GroupRoleMappingEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/GroupRoleMappingEntity.java @@ -35,7 +35,6 @@ * @version $Revision: 1 $ */ @NamedQueries({ - @NamedQuery(name="groupsInRole", query="select g from GroupRoleMappingEntity m, GroupEntity g where m.roleId=:roleId and g=m.group"), @NamedQuery(name="groupHasRole", query="select m from GroupRoleMappingEntity m where m.group = :group and m.roleId = :roleId"), @NamedQuery(name="groupRoleMappings", query="select m from GroupRoleMappingEntity m where m.group = :group"), @NamedQuery(name="groupRoleMappingIds", query="select m.roleId from GroupRoleMappingEntity m where m.group = :group"), diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java index 81fd41a8c324..a299a0b40da5 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java @@ -629,6 +629,8 @@ public Stream getGroupsInRole(final @Parameter(description @Parameter(description = "If false, return a full representation of the {@code GroupRepresentation} objects.") @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) { auth.roles().requireView(roleContainer); + auth.groups().requireList(); + firstResult = firstResult != null ? firstResult : 0; maxResults = maxResults != null ? maxResults : Constants.DEFAULT_MAX_RESULTS; diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/ClientResourceTypeEvaluationTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/ClientResourceTypeEvaluationTest.java index f37a094262cc..40d6914dfd00 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/ClientResourceTypeEvaluationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/ClientResourceTypeEvaluationTest.java @@ -31,9 +31,12 @@ import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientsResource; import org.keycloak.authorization.fgap.AdminPermissionsSchema; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.RoleRepresentation; @@ -480,4 +483,70 @@ public void testManageClientWithAuthorizationSettings() { myResourceServer.setName("somethingNew"); clientResource.update(myResourceServer); } + + @Test + public void testRoleGroupMembersFilteredByGroupPermissions() { + ClientRepresentation myclient = realm.admin().clients().findByClientId("myclient").get(0); + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + String myclientId = myclient.getId(); + + // create a client role + RoleRepresentation role = new RoleRepresentation(); + role.setName("visible-role"); + role.setClientRole(true); + realm.admin().clients().get(myclientId).roles().create(role); + role = realm.admin().clients().get(myclientId).roles().get("visible-role").toRepresentation(); + + // create two groups and assign the role to both + GroupRepresentation visibleGroup = createGroup("visible-group"); + GroupRepresentation hiddenGroup = createGroup("hidden-group"); + + realm.admin().groups().group(visibleGroup.getId()).roles().clientLevel(myclientId).add(List.of(role)); + realm.admin().groups().group(hiddenGroup.getId()).roles().clientLevel(myclientId).add(List.of(role)); + + // create a realm role and assign it to both groups + RoleRepresentation realmRole = new RoleRepresentation(); + realmRole.setName("visible-realm-role"); + realm.admin().roles().create(realmRole); + realmRole = realm.admin().roles().get("visible-realm-role").toRepresentation(); + + realm.admin().groups().group(visibleGroup.getId()).roles().realmLevel().add(List.of(realmRole)); + realm.admin().groups().group(hiddenGroup.getId()).roles().realmLevel().add(List.of(realmRole)); + + // grant limited-admin view permission on the client only + UserPolicyRepresentation policy = createUserPolicy(realm, adminPermissionsClient, "Only My Admin User Policy", myadmin.getId()); + createPermission(adminPermissionsClient, myclientId, clientsType, Set.of(VIEW), policy); + + // grant view permission on visible-group only + createGroupPermission(visibleGroup, Set.of(AdminPermissionsSchema.VIEW), policy); + + // grant view-realm role so the limited admin can access realm-level roles endpoint + String realmMgmtClientId = realm.admin().clients().findByClientId(Constants.REALM_MANAGEMENT_CLIENT_ID).get(0).getId(); + RoleRepresentation viewRealmRole = realm.admin().clients().get(realmMgmtClientId).roles().get(AdminRoles.VIEW_REALM).toRepresentation(); + realm.admin().users().get(myadmin.getId()).roles().clientLevel(realmMgmtClientId).add(List.of(viewRealmRole)); + realm.cleanup().add(r -> r.users().get(myadmin.getId()).roles().clientLevel(realmMgmtClientId).remove(List.of(viewRealmRole))); + + // limited admin can view the client and its role + realmAdminClient.realm(realm.getName()).clients().get(myclientId).toRepresentation(); + realmAdminClient.realm(realm.getName()).clients().get(myclientId).roles().get("visible-role").toRepresentation(); + + // limited admin cannot directly access hidden-group + try { + realmAdminClient.realm(realm.getName()).groups().group(hiddenGroup.getId()).toRepresentation(); + fail("Should not be able to access hidden group directly"); + } catch (ForbiddenException expected) { + } + + // client role group members should only return the visible group, not the hidden one + Set roleGroups = realmAdminClient.realm(realm.getName()).clients().get(myclientId) + .roles().get("visible-role").getRoleGroupMembers(); + assertThat(roleGroups, hasSize(1)); + assertEquals("visible-group", roleGroups.iterator().next().getName()); + + // realm role group members should also be filtered by group permissions + Set realmRoleGroups = realmAdminClient.realm(realm.getName()).roles() + .get("visible-realm-role").getRoleGroupMembers(); + assertThat(realmRoleGroups, hasSize(1)); + assertEquals("visible-group", realmRoleGroups.iterator().next().getName()); + } }