diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/BruteForceUsersResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/BruteForceUsersResource.java index 346cf3bb33a..4393bcf4b41 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/BruteForceUsersResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/BruteForceUsersResource.java @@ -144,16 +144,12 @@ public final Stream searchUser(@QueryParam("search") String search, private Stream searchForUser(Map attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) { attributes.put(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts.toString()); - if (!auth.users().canView()) { - Set groupModels = auth.groups().getGroupsWithViewPermission(); - - if (!groupModels.isEmpty()) { - session.setAttribute(UserModel.GROUPS, groupModels); - } + Set groupIds = auth.groups().getGroupIdsWithViewPermission(); + if (!groupIds.isEmpty()) { + session.setAttribute(UserModel.GROUPS, groupIds); } - Stream userModels = session.users().searchForUserStream(realm, attributes, firstResult, maxResults); - return toRepresentation(realm, usersEvaluator, briefRepresentation, userModels); + return toRepresentation(realm, usersEvaluator, briefRepresentation, session.users().searchForUserStream(realm, attributes, firstResult, maxResults)); } private Stream toRepresentation(RealmModel realm, UserPermissionEvaluator usersEvaluator, diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/AdminPermissionsSchema.java b/server-spi-private/src/main/java/org/keycloak/authorization/AdminPermissionsSchema.java index 5fc43a34f5f..0f0c4a4bfa0 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/AdminPermissionsSchema.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/AdminPermissionsSchema.java @@ -33,6 +33,7 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.ClientProvider; import org.keycloak.models.Constants; +import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.ModelIllegalStateException; @@ -50,27 +51,42 @@ import org.keycloak.representations.idm.authorization.ScopeRepresentation; public class AdminPermissionsSchema extends AuthorizationSchema { - - public static final String USERS_RESOURCE_TYPE = "Users"; + public static final String CLIENTS_RESOURCE_TYPE = "Clients"; + public static final String GROUPS_RESOURCE_TYPE = "Groups"; + public static final String USERS_RESOURCE_TYPE = "Users"; - //scopes + // common scopes public static final String MANAGE = "manage"; public static final String VIEW = "view"; - public static final String IMPERSONATE = "impersonate"; - public static final String MAP_ROLES = "map-roles"; - public static final String MANAGE_GROUP_MEMBERSHIP = "manage-group-membership"; + + // client specific scopes public static final String CONFIGURE = "configure"; public static final String MAP_ROLES_CLIENT_SCOPE = "map-roles-client-scope"; public static final String MAP_ROLES_COMPOSITE = "map-roles-composite"; - public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP)); + // group specific scopes + public static final String MANAGE_MEMBERSHIP = "manage-membership"; + public static final String MANAGE_MEMBERS = "manage-members"; + public static final String VIEW_MEMBERS = "view-members"; + + // user specific scopes + public static final String IMPERSONATE = "impersonate"; + public static final String MAP_ROLES = "map-roles"; + public static final String MANAGE_GROUP_MEMBERSHIP = "manage-group-membership"; + public static final ResourceType CLIENTS = new ResourceType(CLIENTS_RESOURCE_TYPE, Set.of(CONFIGURE, MANAGE, MAP_ROLES, MAP_ROLES_CLIENT_SCOPE, MAP_ROLES_COMPOSITE, VIEW)); + public static final ResourceType GROUPS = new ResourceType(GROUPS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, MANAGE_MEMBERSHIP, MANAGE_MEMBERS, VIEW_MEMBERS)); + public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP)); public static final AdminPermissionsSchema SCHEMA = new AdminPermissionsSchema(); private AdminPermissionsSchema() { - super(Map.of(USERS_RESOURCE_TYPE, USERS, CLIENTS_RESOURCE_TYPE, CLIENTS)); + super(Map.of( + CLIENTS_RESOURCE_TYPE, CLIENTS, + GROUPS_RESOURCE_TYPE, GROUPS, + USERS_RESOURCE_TYPE, USERS + )); } public Resource getOrCreateResource(KeycloakSession session, ResourceServer resourceServer, String policyType, String resourceType, String id) { @@ -86,12 +102,13 @@ public Resource getOrCreateResource(KeycloakSession session, ResourceServer reso return resource; } - String name = null; + String name; - if (USERS.getType().equals(resourceType)) { - name = resolveUser(session, id); - } else if (CLIENTS.getType().equals(resourceType)) { - name = resolveClient(session, id); + switch (resourceType) { + case CLIENTS_RESOURCE_TYPE -> name = resolveClient(session, id); + case GROUPS_RESOURCE_TYPE -> name = resolveGroup(session, id); + case USERS_RESOURCE_TYPE -> name = resolveUser(session, id); + default -> throw new IllegalStateException("Resource type [" + resourceType + "] not found."); } if (name == null) { @@ -161,6 +178,13 @@ public void throwExceptionIfAdminPermissionClient(KeycloakSession session, Strin } } + private String resolveGroup(KeycloakSession session, String id) { + RealmModel realm = session.getContext().getRealm(); + GroupModel group = session.groups().getGroupById(realm, id); + + return group == null ? null : group.getId(); + } + private String resolveUser(KeycloakSession session, String id) { RealmModel realm = session.getContext().getRealm(); UserModel user = session.users().getUserById(realm, id); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 5e72c2b28d6..27268f3b2d2 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -173,10 +173,6 @@ public static GroupRepresentation toRepresentation(GroupModel group, boolean ful return rep; } - public static Stream searchGroupModelsByAttributes(KeycloakSession session, RealmModel realm, Map attributes, Integer first, Integer max) { - return session.groups().searchGroupsByAttributes(realm, attributes, first, max); - } - @Deprecated public static Stream toGroupHierarchy(KeycloakSession session, RealmModel realm, boolean full) { return session.groups().getTopLevelGroupsStream(realm, null, null) diff --git a/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java b/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java index 3f7f2e1debd..5e2415a81e2 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/GroupResource.java @@ -174,10 +174,9 @@ public Stream getSubGroups( @Parameter(description = "The maximum number of results that are to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max, @Parameter(description = "Boolean which defines whether brief groups representations are returned or not (default: false)") @QueryParam("briefRepresentation") @DefaultValue("false") Boolean briefRepresentation) { this.auth.groups().requireView(group); - boolean canViewGlobal = auth.groups().canView(); return paginatedStream( group.getSubGroupsStream(search, exact, -1, -1) - .filter(g -> canViewGlobal || auth.groups().canView(g)), first, max) + .filter(auth.groups()::canView), first, max) .map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, !briefRepresentation))); } @@ -204,7 +203,7 @@ public Response addChild(GroupRepresentation rep) { try { Response.ResponseBuilder builder = Response.status(204); - GroupModel child = null; + GroupModel child; if (rep.getId() != null) { child = realm.getGroupById(rep.getId()); if (child == null) { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java b/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java index d3d2b0e8e56..2e854e0452b 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java @@ -47,7 +47,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.RealmModel; -import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.organization.utils.Organizations; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.services.ErrorResponse; @@ -100,7 +99,7 @@ public Stream getGroups(@QueryParam("search") String search Stream stream; if (Objects.nonNull(searchQuery)) { Map attributes = SearchQueryUtils.getFields(searchQuery); - stream = ModelToRepresentation.searchGroupModelsByAttributes(session, realm, attributes, firstResult, maxResults); + stream = session.groups().searchGroupsByAttributes(realm, attributes, firstResult, maxResults); } else if (Objects.nonNull(search)) { stream = session.groups().searchForGroupByNameStream(realm, search.trim(), exact, firstResult, maxResults); } else { @@ -110,9 +109,8 @@ public Stream getGroups(@QueryParam("search") String search if (populateHierarchy) { return GroupUtils.populateGroupHierarchyFromSubGroups(session, realm, stream, !briefRepresentation, groupsEvaluator); } - boolean canViewGlobal = groupsEvaluator.canView(); - return stream - .filter(g -> canViewGlobal || groupsEvaluator.canView(g)) + + return stream.filter(groupsEvaluator::canView) .map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(groupsEvaluator, g, !briefRepresentation))); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 5510beff282..9e434dd27fe 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -71,7 +71,6 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -396,7 +395,7 @@ public Integer getUsersCount( } else if (userPermissionEvaluator.canView()) { return session.users().getUsersCount(realm, search.trim()); } else { - return session.users().getUsersCount(realm, search.trim(), auth.groups().getGroupsWithViewPermission()); + return session.users().getUsersCount(realm, search.trim(), auth.groups().getGroupIdsWithViewPermission()); } } else if (last != null || first != null || email != null || username != null || emailVerified != null || enabled != null || !searchAttributes.isEmpty()) { Map parameters = new HashMap<>(); @@ -423,12 +422,12 @@ public Integer getUsersCount( if (userPermissionEvaluator.canView()) { return session.users().getUsersCount(realm, parameters); } else { - return session.users().getUsersCount(realm, parameters, auth.groups().getGroupsWithViewPermission()); + return session.users().getUsersCount(realm, parameters, auth.groups().getGroupIdsWithViewPermission()); } } else if (userPermissionEvaluator.canView()) { return session.users().getUsersCount(realm); } else { - return session.users().getUsersCount(realm, auth.groups().getGroupsWithViewPermission()); + return session.users().getUsersCount(realm, auth.groups().getGroupIdsWithViewPermission()); } } @@ -446,16 +445,12 @@ public UserProfileResource userProfile() { private Stream searchForUser(Map attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) { attributes.put(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts.toString()); - if (!auth.users().canView()) { - Set groupModels = auth.groups().getGroupsWithViewPermission(); - - if (!groupModels.isEmpty()) { - session.setAttribute(UserModel.GROUPS, groupModels); - } + Set groupIds = auth.groups().getGroupIdsWithViewPermission(); + if (!groupIds.isEmpty()) { + session.setAttribute(UserModel.GROUPS, groupIds); } - Stream userModels = session.users().searchForUserStream(realm, attributes, firstResult, maxResults).filter(usersEvaluator::canView); - return toRepresentation(realm, usersEvaluator, briefRepresentation, userModels); + return toRepresentation(realm, usersEvaluator, briefRepresentation, session.users().searchForUserStream(realm, attributes, firstResult, maxResults)); } private Stream toRepresentation(RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Stream userModels) { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java index 08da4663b98..3eae0bf9ee0 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java @@ -71,10 +71,10 @@ public static AdminPermissionManagement management(KeycloakSession session, Real } public static void registerListener(ProviderEventManager manager) { - manager.register(new ProviderEventListener() { - @Override - public void onEvent(ProviderEvent event) { - if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) { + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) { + manager.register(new ProviderEventListener() { + @Override + public void onEvent(ProviderEvent event) { if (event instanceof RoleContainerModel.RoleRemovedEvent) { RoleContainerModel.RoleRemovedEvent cast = (RoleContainerModel.RoleRemovedEvent) event; RoleModel role = cast.getRole(); @@ -94,8 +94,8 @@ public void onEvent(ProviderEvent event) { management(cast.getKeycloakSession(), cast.getRealm()).groups().setPermissionsEnabled(cast.getGroup(), false); } } - } - }); + }); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionEvaluator.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionEvaluator.java index c90eba237d5..647fb0e0496 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionEvaluator.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionEvaluator.java @@ -16,6 +16,8 @@ */ package org.keycloak.services.resources.admin.permissions; +import org.keycloak.authorization.AdminPermissionsSchema; +import org.keycloak.models.AdminRoles; import org.keycloak.models.GroupModel; import java.util.Map; @@ -26,41 +28,121 @@ * @version $Revision: 1 $ */ public interface GroupPermissionEvaluator { + + /** + * Returns {@code true} if the caller has at least one of {@link AdminRoles#QUERY_GROUPS}, + * {@link AdminRoles#MANAGE_USERS} or {@link AdminRoles#VIEW_USERS} roles. + *

+ * For V2 only: Also if it has a permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} groups. + */ boolean canList(); + /** + * Throws ForbiddenException if {@link #canList()} returns {@code false}. + */ void requireList(); + /** + * Returns {@code true} if the caller has {@link AdminRoles#MANAGE_USERS} role. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE} the group. + */ boolean canManage(GroupModel group); + /** + * Throws ForbiddenException if {@link #canManage(GroupModel)} returns {@code false}. + */ void requireManage(GroupModel group); + /** + * Returns {@code true} if the caller has one of {@link AdminRoles#MANAGE_USERS} or + * {@link AdminRoles#VIEW_USERS} roles. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} the group. + */ boolean canView(GroupModel group); + /** + * Throws ForbiddenException if {@link #canView(GroupModel)} returns {@code false}. + */ void requireView(GroupModel group); + /** + * Returns {@code true} if the caller has {@link AdminRoles#MANAGE_USERS} role. + *

+ * For V2 only: Also if it has permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} groups. + */ boolean canManage(); + /** + * Throws ForbiddenException if {@link #canManage()} returns {@code false}. + */ void requireManage(); + /** + * Returns {@code true} if the caller has one of {@link AdminRoles#MANAGE_USERS} or + * {@link AdminRoles#VIEW_USERS} roles. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} groups. + */ boolean canView(); + /** + * Throws ForbiddenException if {@link #canView()} returns {@code false}. + */ void requireView(); - boolean getGroupsWithViewPermission(GroupModel group); - + /** + * Throws ForbiddenException if {@link #canViewMembers(GroupModel)} returns {@code false}. + */ void requireViewMembers(GroupModel group); + /** + * Returns {@code true} if {@link UserPermissionEvaluator#canManage()} evaluates to {@code true}. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE_MEMBERS} of the group. + */ boolean canManageMembers(GroupModel group); + /** + * Returns {@code true} if the caller has one of {@link AdminRoles#MANAGE_USERS} role. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE} the group or + * {@link AdminPermissionsSchema#MANAGE_MEMBERSHIP} of the group. + */ boolean canManageMembership(GroupModel group); - + + /** + * Returns {@code true} if {@link UserPermissionEvaluator#canView()} evaluates to {@code true}. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW_MEMBERS} or + * {@link AdminPermissionsSchema#MANAGE_MEMBERS} of the group. + */ boolean canViewMembers(GroupModel group); - + + /** + * Throws ForbiddenException if {@link #canManageMembership(GroupModel)} returns {@code false}. + */ void requireManageMembership(GroupModel group); + /** + * Throws ForbiddenException if {@link #canManageMembership(GroupModel)} returns {@code false}. + */ void requireManageMembers(GroupModel group); + /** + * Returns Map with information what access the caller for the provided group has. + */ Map getAccess(GroupModel group); - Set getGroupsWithViewPermission(); + /** + * If {@link UserPermissionEvaluator#canView()} evaluates to {@code true}, returns empty set. + * + * @return Stream of IDs of groups with view permission. + */ + Set getGroupIdsWithViewPermission(); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissions.java index b32bc01c2b1..7b8f670d6da 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissions.java @@ -53,9 +53,9 @@ class GroupPermissions implements GroupPermissionEvaluator, GroupPermissionManag private static final String RESOURCE_NAME_PREFIX = "group.resource."; private final AuthorizationProvider authz; - private final MgmtPermissions root; - private final ResourceStore resourceStore; - private final PolicyStore policyStore; + protected final MgmtPermissions root; + protected final ResourceStore resourceStore; + protected final PolicyStore policyStore; GroupPermissions(AuthorizationProvider authz, MgmtPermissions root) { this.authz = authz; @@ -73,7 +73,6 @@ private static String getGroupResourceName(GroupModel group) { return RESOURCE_NAME_PREFIX + group.getId(); } - private static String getManagePermissionGroup(GroupModel group) { return "manage.permission.group." + group.getId(); } @@ -147,7 +146,7 @@ private void initialize(GroupModel group) { @Override public boolean canList() { - return canView() || root.hasOneAdminRole(AdminRoles.VIEW_USERS, AdminRoles.MANAGE_USERS, AdminRoles.QUERY_GROUPS); + return root.hasOneAdminRole(AdminRoles.QUERY_GROUPS) || canView(); } @Override @@ -273,7 +272,7 @@ public void requireView(GroupModel group) { @Override public boolean canManage() { - return root.users().canManageDefault(); + return root.hasOneAdminRole(AdminRoles.MANAGE_USERS); } @Override @@ -282,9 +281,10 @@ public void requireManage() { throw new ForbiddenException(); } } + @Override public boolean canView() { - return root.users().canViewDefault(); + return root.hasOneAdminRole(AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS); } @Override @@ -295,24 +295,8 @@ public void requireView() { } @Override - public boolean getGroupsWithViewPermission(GroupModel group) { - if (root.users().canView() || root.users().canManage()) { - return true; - } - - if (!root.isAdminSameRealm()) { - return false; - } - - ResourceServer server = root.realmResourceServer(); - if (server == null) return false; - - return hasPermission(group, VIEW_MEMBERS_SCOPE, MANAGE_MEMBERS_SCOPE); - } - - @Override - public Set getGroupsWithViewPermission() { - if (root.users().canView() || root.users().canManage()) return Collections.emptySet(); + public Set getGroupIdsWithViewPermission() { + if (root.users().canView()) return Collections.emptySet(); if (!root.isAdminSameRealm()) { return Collections.emptySet(); @@ -337,7 +321,7 @@ public Set getGroupsWithViewPermission() { @Override public void requireViewMembers(GroupModel group) { - if (!getGroupsWithViewPermission(group)) { + if (!canViewMembers(group)) { throw new ForbiddenException(); } } @@ -353,7 +337,7 @@ public boolean canViewMembers(GroupModel group) { ResourceServer server = root.realmResourceServer(); if (server == null) return false; - return hasPermission(group, VIEW_MEMBERS_SCOPE); + return hasPermission(group, VIEW_MEMBERS_SCOPE, MANAGE_MEMBERS_SCOPE); } @Override diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java new file mode 100644 index 00000000000..82035684add --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java @@ -0,0 +1,211 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.services.resources.admin.permissions; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.keycloak.authorization.AdminPermissionsSchema; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.Resource; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.models.AdminRoles; +import org.keycloak.representations.idm.authorization.Permission; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; + +class GroupPermissionsV2 extends GroupPermissions { + + private final KeycloakSession session; + + GroupPermissionsV2(KeycloakSession session, AuthorizationProvider authz, MgmtPermissions root) { + super(authz, root); + this.session = session; + } + + @Override + public boolean canView() { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS)) { + return true; + } + + return hasPermission(null, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE); + } + + @Override + public boolean canView(GroupModel group) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS)) { + return true; + } + + return hasPermission(group.getId(), AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE); + } + + @Override + public boolean canManage() { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { + return true; + } + + return hasPermission(null, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE); + } + + @Override + public boolean canManage(GroupModel group) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { + return true; + } + + return hasPermission(group.getId(), AdminPermissionsSchema.MANAGE); + } + + @Override + public boolean canViewMembers(GroupModel group) { + if (root.users().canView()) return true; + + return hasPermission(group.getId(), AdminPermissionsSchema.VIEW_MEMBERS, AdminPermissionsSchema.MANAGE_MEMBERS); + } + + @Override + public boolean canManageMembers(GroupModel group) { + if (root.users().canManage()) return true; + + return hasPermission(group.getId(), AdminPermissionsSchema.MANAGE_MEMBERS); + } + + @Override + public boolean canManageMembership(GroupModel group) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { + return true; + } + + return hasPermission(group.getId(), AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.MANAGE_MEMBERSHIP); + } + + @Override + public Set getGroupIdsWithViewPermission() { + if (root.users().canView()) return Collections.emptySet(); + + if (!root.isAdminSameRealm()) { + return Collections.emptySet(); + } + + ResourceServer server = root.realmResourceServer(); + + if (server == null) { + return Collections.emptySet(); + } + + Set granted = new HashSet<>(); + + resourceStore.findByType(server, AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, groupResource -> { + if (hasPermission(groupResource.getId(), AdminPermissionsSchema.VIEW_MEMBERS, AdminPermissionsSchema.MANAGE_MEMBERS)) { + granted.add(groupResource.getId()); + } + }); + + return granted; + } + + private boolean hasPermission(String groupId, String... scopes) { + if (!root.isAdminSameRealm()) { + return false; + } + + ResourceServer server = root.realmResourceServer(); + + if (server == null) { + return false; + } + + Resource resource = groupId == null ? null : resourceStore.findByName(server, groupId); + + if (resource == null) { + resource = AdminPermissionsSchema.SCHEMA.getResourceTypeResource(session, server, AdminPermissionsSchema.GROUPS_RESOURCE_TYPE); + + // check if there is a permission for "all-groups". If so, proceed with the evaluation to check scopes + if (policyStore.findByResource(server, resource).isEmpty()) { + return false; + } + } + + Collection permissions = root.evaluatePermission(new ResourcePermission(resource, resource.getScopes(), server), server); + + List expectedScopes = Arrays.asList(scopes); + + for (Permission permission : permissions) { + for (String scope : permission.getScopes()) { + if (expectedScopes.contains(scope)) { + return true; + } + } + } + + return false; + } + + @Override + public boolean isPermissionsEnabled(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public void setPermissionsEnabled(GroupModel group, boolean enable) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Policy viewMembersPermission(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Policy manageMembersPermission(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Policy manageMembershipPermission(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Policy viewPermission(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Policy managePermission(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Resource resource(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } + + @Override + public Map getPermissions(GroupModel group) { + throw new UnsupportedOperationException("Not supported in V2"); + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissionsV2.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissionsV2.java index 70e2e7676e7..7075c4de6f5 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissionsV2.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissionsV2.java @@ -24,6 +24,7 @@ class MgmtPermissionsV2 extends MgmtPermissions { + private GroupPermissionsV2 groupPermissions; private UserPermissionsV2 userPermissions; private ClientPermissionsV2 clientPermissions; @@ -53,6 +54,13 @@ public ClientModel getRealmPermissionsClient() { return realm.getAdminPermissionsClient(); } + @Override + public GroupPermissions groups() { + if (groupPermissions != null) return groupPermissions; + groupPermissions = new GroupPermissionsV2(session, authz, this); + return groupPermissions; + } + @Override public UserPermissions users() { if (userPermissions != null) return userPermissions; diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/RolePermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/RolePermissions.java index daf3369f204..d87fc868eb9 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/RolePermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/RolePermissions.java @@ -299,7 +299,7 @@ private boolean adminConflictMessage(RoleModel role) { */ @Override public boolean canMapRole(RoleModel role) { - if (root.users().canManageDefault()) return checkAdminRoles(role); + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) return checkAdminRoles(role); if (!root.isAdminSameRealm()) { return false; } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java index 35e06caf3c3..242acff1a81 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java @@ -16,7 +16,10 @@ */ package org.keycloak.services.resources.admin.permissions; +import org.keycloak.authorization.AdminPermissionsSchema; +import org.keycloak.models.AdminRoles; import org.keycloak.models.ClientModel; +import org.keycloak.models.ImpersonationConstants; import org.keycloak.models.UserModel; import java.util.Map; @@ -26,30 +29,138 @@ * @version $Revision: 1 $ */ public interface UserPermissionEvaluator { + + /** + * Throws ForbiddenException if {@link #canManage()} returns {@code false}. + */ void requireManage(); + + /** + * Throws ForbiddenException if {@link #canManage(UserModel)} returns {@code false}. + */ void requireManage(UserModel user); + + /** + * Returns {@code true} if the caller has {@link AdminRoles#MANAGE_USERS} role. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE} users. + */ boolean canManage(); + + /** + * Returns {@code true} if the caller has {@link AdminRoles#MANAGE_USERS} role. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE} the user. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE_MEMBERS} + * of the group chain the user is associated with. + */ boolean canManage(UserModel user); + /** + * Throws ForbiddenException if {@link #canQuery()} returns {@code false}. + */ void requireQuery(); + + /** + * Returns {@code true} if the caller has at least one of {@link AdminRoles#QUERY_USERS}, + * {@link AdminRoles#MANAGE_USERS} or {@link AdminRoles#VIEW_USERS} roles. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} users. + */ boolean canQuery(); + /** + * Throws ForbiddenException if {@link #canView()} returns {@code false}. + */ void requireView(); + + /** + * Throws ForbiddenException if {@link #canView(UserModel)} returns {@code false}. + */ void requireView(UserModel user); + + /** + * Returns {@code true} if the caller has one of {@link AdminRoles#MANAGE_USERS} or + * {@link AdminRoles#VIEW_USERS} roles. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} users. + */ boolean canView(); + + /** + * Returns {@code true} if the caller has at least one of {@link AdminRoles#MANAGE_USERS} or + * {@link AdminRoles#VIEW_USERS} roles. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW} or + * {@link AdminPermissionsSchema#MANAGE} the user. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#VIEW_MEMBERS} + * of the group chain the user is associated with. + */ boolean canView(UserModel user); + /** + * Throws ForbiddenException if {@link #canImpersonate(UserModel, ClientModel)} returns {@code false}. + */ void requireImpersonate(UserModel user); + + /** + * Returns {@code true} if the caller has the {@link ImpersonationConstants#IMPERSONATION_ROLE}. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#IMPERSONATE} users. + */ boolean canImpersonate(); + + /** + * Returns {@code true} if the caller has the {@link ImpersonationConstants#IMPERSONATION_ROLE}. + *

+ * NOTE: If requester is provided, it's clientId is added to evaluation context. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#IMPERSONATE} the user. + */ boolean canImpersonate(UserModel user, ClientModel requester); - boolean isImpersonatable(UserModel user, ClientModel requester); + /** + * Returns Map with information what access the caller for the provided user has. + */ Map getAccess(UserModel user); + /** + * Throws ForbiddenException if {@link #canMapRoles(UserModel)} returns {@code false}. + */ void requireMapRoles(UserModel user); + + /** + * Returns {@code true} if the caller has {@link AdminRoles#MANAGE_USERS} role. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE} the user or + * {@link AdminPermissionsSchema#MAP_ROLES} of the user. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE_MEMBERS} + * of the group chain the user is associated with. + */ boolean canMapRoles(UserModel user); + /** + * Throws ForbiddenException if {@link #canManageGroupMembership(UserModel)} returns {@code false}. + */ void requireManageGroupMembership(UserModel user); + + /** + * Returns {@code true} if the caller has {@link AdminRoles#MANAGE_USERS} role. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE} the user or + * {@link AdminPermissionsSchema#MANAGE_GROUP_MEMBERSHIP} of the user. + *

+ * Or if it has a permission to {@link AdminPermissionsSchema#MANAGE_MEMBERS} + * of the group chain the user is associated with. + */ boolean canManageGroupMembership(UserModel user); + + @Deprecated + boolean isImpersonatable(UserModel user, ClientModel requester); void grantIfNoPermission(boolean grantIfNoPermission); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java index d9bbf9bc5f2..a0eae42257c 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java @@ -177,10 +177,6 @@ public void setPermissionsEnabled(boolean enable) { } } - public boolean canManageDefault() { - return root.hasOneAdminRole(AdminRoles.MANAGE_USERS); - } - @Override public Resource resource() { ResourceServer server = root.realmResourceServer(); @@ -235,7 +231,7 @@ public Policy userImpersonatedPermission() { */ @Override public boolean canManage() { - if (canManageDefault()) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { return true; } @@ -274,7 +270,7 @@ public void requireManage(UserModel user) { @Override public boolean canQuery() { - return canView() || root.hasOneAdminRole(AdminRoles.QUERY_USERS); + return root.hasOneAdminRole(AdminRoles.QUERY_USERS) || canView(); } @Override @@ -299,7 +295,7 @@ public void requireQuery() { */ @Override public boolean canView() { - if (canViewDefault() || canManageDefault()) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS)) { return true; } @@ -585,15 +581,11 @@ private boolean evaluateHierarchy(Predicate eval, GroupModel group, protected boolean canManageByGroup(UserModel user) { if (authz == null) return false; - return evaluateHierarchy(user, (group) -> root.groups().canManageMembers(group)); - + return evaluateHierarchy(user, root.groups()::canManageMembers); } + protected boolean canViewByGroup(UserModel user) { if (authz == null) return false; - return evaluateHierarchy(user, (group) -> root.groups().getGroupsWithViewPermission(group)); - } - - public boolean canViewDefault() { - return root.hasOneAdminRole(AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS); + return evaluateHierarchy(user, root.groups()::canViewMembers); } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionsV2.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionsV2.java index c76d0bc72f2..7cc176998a2 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionsV2.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionsV2.java @@ -45,32 +45,20 @@ class UserPermissionsV2 extends UserPermissions { @Override public boolean canView(UserModel user) { - if (root.hasOneAdminRole(AdminRoles.ADMIN, AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS)) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS, AdminRoles.VIEW_USERS)) { return true; } - boolean result = hasPermission(user, null, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE); - - if (!result) { - return canViewByGroup(user); - } - - return result; + return hasPermission(user, null, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE) || canViewByGroup(user); } @Override public boolean canManage(UserModel user) { - if (root.hasOneAdminRole(AdminRoles.ADMIN, AdminRoles.MANAGE_USERS)) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { return true; } - boolean result = hasPermission(user, null, AdminPermissionsSchema.MANAGE); - - if (!result) { - return canManageByGroup(user); - } - - return result; + return hasPermission(user, null, AdminPermissionsSchema.MANAGE) || canManageByGroup(user); } @Override @@ -87,20 +75,20 @@ public boolean canImpersonate(UserModel user, ClientModel requester) { @Override public boolean canMapRoles(UserModel user) { - if (canManage(user)) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { return true; } - return hasPermission(user, null, AdminPermissionsSchema.MAP_ROLES); + return hasPermission(user, null, AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.MAP_ROLES) || canManageByGroup(user); } @Override public boolean canManageGroupMembership(UserModel user) { - if (canManage(user)) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { return true; } - return hasPermission(user, null, AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP); + return hasPermission(user, null, AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP) || canManageByGroup(user); } private boolean hasPermission(UserModel user, EvaluationContext context, String... scopes) { diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/util/ApiUtil.java b/test-framework/core/src/main/java/org/keycloak/testframework/util/ApiUtil.java index 8c6f065e23b..8fa519bdc4f 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/util/ApiUtil.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/util/ApiUtil.java @@ -6,11 +6,11 @@ public class ApiUtil { public static String handleCreatedResponse(Response response) { - Assertions.assertEquals(201, response.getStatus()); - String path = response.getLocation().getPath(); - String uuid = path.substring(path.lastIndexOf('/') + 1); - response.close(); - return uuid; + try (response) { + Assertions.assertEquals(201, response.getStatus()); + String path = response.getLocation().getPath(); + return path.substring(path.lastIndexOf('/') + 1); + } } } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeEvaluationTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeEvaluationTest.java new file mode 100644 index 00000000000..5ab4431d433 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeEvaluationTest.java @@ -0,0 +1,320 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.admin.authz.fgap; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.keycloak.authorization.AdminPermissionsSchema.MANAGE; +import static org.keycloak.authorization.AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP; +import static org.keycloak.authorization.AdminPermissionsSchema.MANAGE_MEMBERS; +import static org.keycloak.authorization.AdminPermissionsSchema.MANAGE_MEMBERSHIP; +import static org.keycloak.authorization.AdminPermissionsSchema.VIEW; +import static org.keycloak.authorization.AdminPermissionsSchema.VIEW_MEMBERS; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.ScopePermissionsResource; +import org.keycloak.authorization.AdminPermissionsSchema; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.representations.idm.authorization.UserPolicyRepresentation; +import org.keycloak.testframework.annotations.InjectAdminClient; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.ManagedUser; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.util.ApiUtil; + +@KeycloakIntegrationTest(config = KeycloakAdminPermissionsServerConfig.class) +public class GroupResourceTypeEvaluationTest extends AbstractPermissionTest { + + @InjectUser(ref = "alice") + ManagedUser userAlice; + + @InjectAdminClient(mode = InjectAdminClient.Mode.MANAGED_REALM, client = "myclient", user = "myadmin") + Keycloak realmAdminClient; + + private final String groupName = "top_group"; + private final GroupRepresentation topGroup = new GroupRepresentation();; + + @BeforeEach // cannot use @BeforeAll, realm is not initializaed yet + public void onBefore() { + topGroup.setName(groupName); + try (Response response = realm.admin().groups().add(topGroup)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + topGroup.setId(ApiUtil.handleCreatedResponse(response)); + realm.cleanup().add(r -> r.groups().group(topGroup.getId()).remove()); + } + realm.admin().users().get(userAlice.getId()).joinGroup(topGroup.getId()); + } + + @AfterEach + public void onAfter() { + ScopePermissionsResource permissions = getScopePermissionsResource(client); + + for (ScopePermissionRepresentation permission : permissions.findAll(null, null, null, -1, -1)) { + permissions.findById(permission.getId()).remove(); + } + } + + @Test + public void testCanViewUserByViewGroupMembers() { + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + UserPolicyRepresentation allowMyAdminPermission = createUserPolicy(realm, client, "Only My Admin User Policy", myadmin.getId()); + + // my admin should NOT be able to see Alice + List search = realmAdminClient.realm(realm.getName()).users().search(null, -1, -1); + assertTrue(search.isEmpty()); + + // allow my admin to view members of the group where Alice is member of + createGroupPermission(topGroup, Set.of(VIEW_MEMBERS), allowMyAdminPermission); + + // my admin should be able to see Alice due to her membership and VIEW_MEMBERS permission + search = realmAdminClient.realm(realm.getName()).users().search(null, -1, -1); + assertEquals(1, search.size()); + assertEquals(userAlice.getUsername(), search.get(0).getUsername()); + } + + @Test + public void testCanViewUserByManageGroupMembers() { + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + UserPolicyRepresentation allowMyAdminPermission = createUserPolicy(realm, client, "Only My Admin User Policy", myadmin.getId()); + + // my admin should NOT be able to see Alice + List search = realmAdminClient.realm(realm.getName()).users().search(null, -1, -1); + assertTrue(search.isEmpty()); + + // my admin should not be able to manage yet + try { + realmAdminClient.realm(realm.getName()).users().get(userAlice.getId()).update(UserConfigBuilder.create().email("email@test.com").build()); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + + // allow my admin to manage members of the group where Alice is member of + createGroupPermission(topGroup, Set.of(MANAGE_MEMBERS), allowMyAdminPermission); + + // my admin should be able to see Alice due to her membership and MANAGE_MEMBERS permission + search = realmAdminClient.realm(realm.getName()).users().search(null, -1, -1); + assertEquals(1, search.size()); + assertEquals(userAlice.getUsername(), search.get(0).getUsername()); + + // my admin should be able to update Alice due to her membership and MANAGE_MEMBERS permission + realmAdminClient.realm(realm.getName()).users().get(userAlice.getId()).update(UserConfigBuilder.create().email("email@test.com").build()); + assertEquals("email@test.com", realmAdminClient.realm(realm.getName()).users().get(userAlice.getId()).toRepresentation().getEmail()); + } + + @Test + public void testManageAllGroups() { + // myadmin shouldn't be able to create groups just yet + try (Response response = realmAdminClient.realm(realm.getName()).groups().add(new GroupRepresentation())) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + + // myadmin shouldn't be able to add child for a group + try (Response response = realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).subGroup(new GroupRepresentation())) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + + // myadmin shouldn't be able to map roles for group + try { + realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).roles().realmLevel().add(List.of()); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + + //create all-groups permission for "myadmin" (so that myadmin can manage all groups in the realm) + UserPolicyRepresentation policy = createUserPolicy(realm, client, "Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId()); + createAllGroupsPermission(policy, Set.of(MANAGE)); + + // creating group requires manage scope + GroupRepresentation group = new GroupRepresentation(); + group.setName("testGroup"); + String testGroupId = ApiUtil.handleCreatedResponse(realmAdminClient.realm(realm.getName()).groups().add(group)); + group.setId(testGroupId); + + // it should be possible to update the group due to fallback to all-groups permission + group.setName("newGroup"); + realmAdminClient.realm(realm.getName()).groups().group(testGroupId).update(group); + assertEquals("newGroup", realmAdminClient.realm(realm.getName()).groups().group(testGroupId).toRepresentation().getName()); + + // it should be possible to add the child to the group now + try (Response response = realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).subGroup(group)) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + // it should be possible to map roles now + // trying with non existent role as we need to test manage permission for groups (not `auth.roles().requireMapRole(roleModel);`) + // expecting NotFoundException + try { + realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).roles().realmLevel().add(List.of(new RoleRepresentation("non_existent", null, false))); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(NotFoundException.class)); + } + } + + @Test + public void testManageGroup() { + // create group + GroupRepresentation myGroup = new GroupRepresentation(); + myGroup.setName("my_group"); + + try (Response response = realm.admin().groups().add(myGroup)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + myGroup.setId(ApiUtil.handleCreatedResponse(response)); + realm.cleanup().add(r -> r.groups().group(myGroup.getId()).remove()); + } + + //create group permission for "myadmin" to manage the myGroup + UserPolicyRepresentation policy = createUserPolicy(realm, client, "Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId()); + createGroupPermission(myGroup, Set.of(MANAGE), policy); + + // myadmin shouldn't be able to update the topGroup + try { + realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).update(myGroup); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + + // it should be possible to update the myGroup + myGroup.setName("newGroup"); + realmAdminClient.realm(realm.getName()).groups().group(myGroup.getId()).update(myGroup); + assertEquals("newGroup", realmAdminClient.realm(realm.getName()).groups().group(myGroup.getId()).toRepresentation().getName()); + + // it should not be possible to add child to the topGroup + GroupRepresentation subGroup = new GroupRepresentation(); + subGroup.setName("subGroup"); + try (Response response = realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).subGroup(subGroup)) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + + // it should be possible to add child to the myGroup + try (Response response = realmAdminClient.realm(realm.getName()).groups().group(myGroup.getId()).subGroup(subGroup)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + + // it should not be possible to map roles to topGroup + try { + realmAdminClient.realm(realm.getName()).groups().group(topGroup.getId()).roles().realmLevel().add(List.of(new RoleRepresentation("non_existent", null, false))); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + + // it should be possible to map roles to myGroup + // trying with non existent role as we need to test manage permission for groups (not `auth.roles().requireMapRole(roleModel);`) + // expecting NotFoundException + try { + realmAdminClient.realm(realm.getName()).groups().group(myGroup.getId()).roles().realmLevel().add(List.of(new RoleRepresentation("non_existent", null, false))); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(NotFoundException.class)); + } + } + + @Test + public void testViewGroups() { + UserPolicyRepresentation policy = createUserPolicy(realm, client, "Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId()); + + // should not see the groups + List search = realmAdminClient.realm(realm.getName()).groups().groups(); + assertThat(search, hasSize(0)); + + // create group + GroupRepresentation myGroup = new GroupRepresentation(); + myGroup.setName("my_group"); + + try (Response response = realm.admin().groups().add(myGroup)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + myGroup.setId(ApiUtil.handleCreatedResponse(response)); + realm.cleanup().add(r -> r.groups().group(myGroup.getId()).remove()); + } + + //create permission to view myGroup + createGroupPermission(myGroup, Set.of(VIEW), policy); + + // myadmin should be able to view only myGroup + search = realmAdminClient.realm(realm.getName()).groups().groups(); + assertThat(search, hasSize(1)); + assertThat(search.get(0).getName(), equalTo(myGroup.getName())); + + // create view all groups permission for myadmin + createAllGroupsPermission(policy, Set.of(VIEW)); + + // now two groups should be returned (myGroup, topGroup) + search = realmAdminClient.realm(realm.getName()).groups().groups(); + assertThat(search, hasSize(2)); + } + + @Test + public void testManageGroupMembership() { + // myadmin shouldn't be able to manage group membership of the user just yet + try { + realmAdminClient.realm(realm.getName()).users().get(userAlice.getId()).joinGroup("no-such"); + fail("Expected Exception wasn't thrown."); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + + //create all-users permission for "myadmin" (so that myadmin can add users into a group) + UserPolicyRepresentation policy = createUserPolicy(realm, client, "Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId()); + createAllUserPermission(policy, Set.of(MANAGE_GROUP_MEMBERSHIP)); + + //create group permission to allow manage membership for the group + createGroupPermission(topGroup, Set.of(MANAGE_MEMBERSHIP), policy); + + + //create new user + String bobId = ApiUtil.handleCreatedResponse(realm.admin().users().create(UserConfigBuilder.create().username("bob").build())); + realm.cleanup().add(r -> r.users().delete(bobId)); + + //check myadmin can manage membership + realmAdminClient.realm(realm.getName()).users().get(bobId).joinGroup(topGroup.getId()); + } + + private ScopePermissionRepresentation createAllGroupsPermission(UserPolicyRepresentation policy, Set scopes) { + return createAllPermission(client, AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, policy, scopes); + } + + private ScopePermissionRepresentation createAllUserPermission(UserPolicyRepresentation policy, Set scopes) { + return createAllPermission(client, AdminPermissionsSchema.USERS_RESOURCE_TYPE, policy, scopes); + } + + private ScopePermissionRepresentation createGroupPermission(GroupRepresentation group, Set scopes, UserPolicyRepresentation... policies) { + return createPermission(client, group.getId(), AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, scopes, policies); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/RealmAdminPermissionsConfig.java b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/RealmAdminPermissionsConfig.java index 8dac096eb23..bdae23f71e4 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/RealmAdminPermissionsConfig.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/RealmAdminPermissionsConfig.java @@ -31,7 +31,9 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) { .email("myadmin@localhost") .emailVerified() .password("password") - .clientRoles(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.QUERY_USERS); + .clientRoles(Constants.REALM_MANAGEMENT_CLIENT_ID, + AdminRoles.QUERY_USERS, + AdminRoles.QUERY_GROUPS); realm.addClient("myclient") .secret("mysecret") .directAccessGrants(); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/UserResourceTypeEvaluationTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/UserResourceTypeEvaluationTest.java index 13996fc34dd..d1cb8502cec 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/UserResourceTypeEvaluationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/UserResourceTypeEvaluationTest.java @@ -32,7 +32,6 @@ import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; -import java.util.Arrays; import java.util.List; import java.util.Set;