diff --git a/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc b/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc index ae7ec36dac1..d2a01d32751 100644 --- a/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc +++ b/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc @@ -68,6 +68,8 @@ set of scopes: | *manage-group-membership* | Defines if a realm administrator can assign or unassign users to/from groups. | None | *map-roles* | Defines if a realm administrator can assign or unassign roles to/from users. | None | *impersonate* | Defines if a realm administrator can impersonate other users. | `impersonate-members` +| *reset-password* | Defines if a realm administrator can reset user passwords. By default, this scope falls | None + back to `manage` scope behavior (configurable via `fgap.v2.resetPassword.fallbackToManageUsers`). |=== The user resource type has a strong relationship with some of the permissions you can set to groups. Most of the time, diff --git a/docs/documentation/upgrading/topics/changes/changes-26_4_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_4_0.adoc index 4ed8e20c970..e1ece1c3cd4 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_4_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_4_0.adoc @@ -4,7 +4,22 @@ Breaking changes are identified as requiring changes from existing users to their configurations. In minor or patch releases we will only do breaking changes to fix bugs. -=== +=== Fine-grained admin permissions: RESET_PASSWORD scope for Users + +A new `reset-password` scope has been added to the Users resource type in Fine-Grained Admin Permissions v2. This scope allows administrators to grant password reset permissions independently from the broader `manage` scope. + +By default, the behavior remains compatible with previous versions through the `fallbackToManageUsers` configuration option, which is set to `true` by default. When this fallback is enabled, password reset permissions will use the existing `manage` scope behavior. + +To enable the new granular password reset permissions, set the configuration option: + +[source] +---- +fgap.v2.resetPassword.fallbackToManageUsers=false +---- + +When the fallback is disabled, only explicit `reset-password` scope permissions will allow password reset operations, providing more fine-grained control over administrative access. + +For more information about fine-grained admin permissions, see the link:{adminguide_finegrained_link}[{adminguide_finegrained_name}] chapter in the {adminguide_name}. // ------------------------ Notable changes ------------------------ // == Notable changes diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/Decision.java b/server-spi-private/src/main/java/org/keycloak/authorization/Decision.java index 1fccee6937f..f00d98dca97 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/Decision.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/Decision.java @@ -42,4 +42,14 @@ public interface Decision { default void onComplete(ResourcePermission permission) { } + + /** + * Checks if the given {@code scope} is associated with any policy processed in this decision. + * + * @param scope the scope name + * @return {@code true} if the scope is associated with a policy. Otherwise, {@code false}. + */ + default boolean isEvaluated(String scope) { + return false; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java index 64234313525..94e6074878f 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java @@ -95,13 +95,14 @@ public class AdminPermissionsSchema extends AuthorizationSchema { // user specific scopes public static final String IMPERSONATE = "impersonate"; + public static final String RESET_PASSWORD = "reset-password"; public static final String MANAGE_GROUP_MEMBERSHIP = "manage-group-membership"; public static final ResourceType CLIENTS = new ResourceType(CLIENTS_RESOURCE_TYPE, Set.of(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, IMPERSONATE_MEMBERS)); public static final ResourceType ROLES = new ResourceType(ROLES_RESOURCE_TYPE, Set.of(MAP_ROLE, MAP_ROLE_CLIENT_SCOPE, MAP_ROLE_COMPOSITE)); - public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP), Map.of(VIEW, Set.of(VIEW_MEMBERS), MANAGE, Set.of(MANAGE_MEMBERS), IMPERSONATE, Set.of(IMPERSONATE_MEMBERS)), GROUPS.getType()); + public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP, RESET_PASSWORD), Map.of(VIEW, Set.of(VIEW_MEMBERS), MANAGE, Set.of(MANAGE_MEMBERS), IMPERSONATE, Set.of(IMPERSONATE_MEMBERS)), GROUPS.getType()); private static final String SKIP_EVALUATION = "kc.authz.fgap.skip"; public static final AdminPermissionsSchema SCHEMA = new AdminPermissionsSchema(); @@ -531,4 +532,4 @@ public class AdminPermissionsSchema extends AuthorizationSchema { return Boolean.parseBoolean(session.getAttributeOrDefault(SKIP_EVALUATION, Boolean.FALSE.toString())); } -} +} \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/FGAPDecision.java b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/FGAPDecision.java index cad93c0233f..3348e7d3e2b 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/FGAPDecision.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/FGAPDecision.java @@ -55,4 +55,9 @@ class FGAPDecision implements Decision { public void onComplete(ResourcePermission permission) { decision.onComplete(permission); } + + @Override + public boolean isEvaluated(String scope) { + return decision.isEvaluated(scope); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/IterablePermissionEvaluator.java b/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/IterablePermissionEvaluator.java index 652b7bff3a7..fb9d065628f 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/IterablePermissionEvaluator.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/IterablePermissionEvaluator.java @@ -88,10 +88,16 @@ class IterablePermissionEvaluator implements PermissionEvaluator { @Override public Collection evaluate(ResourceServer resourceServer, AuthorizationRequest request) { + DecisionPermissionCollector decision = getDecision(resourceServer, request, DecisionPermissionCollector.class); + return decision.results(); + } + + @Override + public > D getDecision(ResourceServer resourceServer, AuthorizationRequest request, Class decisionType) { DecisionPermissionCollector decision = new DecisionPermissionCollector(authorizationProvider, resourceServer, request); evaluate(decision); - return decision.results(); + return decisionType.cast(decision); } } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/PermissionEvaluator.java b/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/PermissionEvaluator.java index 196c52c508e..f46c1f09b06 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/PermissionEvaluator.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/PermissionEvaluator.java @@ -34,4 +34,5 @@ public interface PermissionEvaluator { D evaluate(D decision); Collection evaluate(ResourceServer resourceServer, AuthorizationRequest request); + > D getDecision(ResourceServer resourceServer, AuthorizationRequest request, Class decisionType); } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/UnboundedPermissionEvaluator.java b/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/UnboundedPermissionEvaluator.java index 3ce1909d0e1..f44ec54edee 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/UnboundedPermissionEvaluator.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/permission/evaluator/UnboundedPermissionEvaluator.java @@ -60,10 +60,16 @@ public class UnboundedPermissionEvaluator implements PermissionEvaluator { @Override public Collection evaluate(ResourceServer resourceServer, AuthorizationRequest request) { + DecisionPermissionCollector decision = getDecision(resourceServer, request, DecisionPermissionCollector.class); + return decision.results(); + } + + @Override + public > D getDecision(ResourceServer resourceServer, AuthorizationRequest request, Class decisionType) { DecisionPermissionCollector decision = new DecisionPermissionCollector(authorizationProvider, resourceServer, request); evaluate(decision); - return decision.results(); + return decisionType.cast(decision); } } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/policy/evaluation/AbstractDecisionCollector.java b/server-spi-private/src/main/java/org/keycloak/authorization/policy/evaluation/AbstractDecisionCollector.java index 3eff4f48e22..c405fdf6aea 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/policy/evaluation/AbstractDecisionCollector.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/policy/evaluation/AbstractDecisionCollector.java @@ -20,6 +20,7 @@ package org.keycloak.authorization.policy.evaluation; import org.keycloak.authorization.Decision; import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.authorization.policy.evaluation.Result.PolicyResult; import org.keycloak.representations.idm.authorization.DecisionStrategy; import java.util.Collection; @@ -129,4 +130,19 @@ public abstract class AbstractDecisionCollector implements Decision return true; } } + + @Override + public boolean isEvaluated(String scope) { + for (Result result : results.values()) { + for (PolicyResult policyResult : result.getResults()) { + Policy policy = policyResult.getPolicy(); + + if (policy.getScopes().stream().anyMatch(s -> s.getName().equals(scope))) { + return true; + } + } + } + + return false; + } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index e26b1315cdd..cfcabe57801 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -760,7 +760,7 @@ public class UserResource { @APIResponse(responseCode = "500", description = "Internal Server Error", content = @Content(schema = @Schema(implementation = ErrorRepresentation.class))) }) public void resetPassword(@Parameter(description = "The representation must contain a rawPassword with the plain-text password") CredentialRepresentation cred) { - auth.users().requireManage(user); + auth.users().requireResetPassword(user); if (cred == null || cred.getValue() == null) { throw new BadRequestException("No password provided"); } @@ -1324,4 +1324,4 @@ public class UserResource { this.lifespan = lifespan; } } -} +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/FineGrainedAdminPermissionEvaluator.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/FineGrainedAdminPermissionEvaluator.java index 51ee4def4e2..65e81b9fcb4 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/FineGrainedAdminPermissionEvaluator.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/FineGrainedAdminPermissionEvaluator.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; import org.keycloak.authorization.fgap.AdminPermissionsSchema; @@ -29,6 +30,7 @@ import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.ResourceServer; import org.keycloak.authorization.model.ResourceWrapper; import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.authorization.policy.evaluation.DecisionPermissionCollector; import org.keycloak.authorization.policy.evaluation.EvaluationContext; import org.keycloak.authorization.store.PolicyStore; import org.keycloak.authorization.store.ResourceStore; @@ -52,7 +54,37 @@ class FineGrainedAdminPermissionEvaluator { return hasPermission(model.getId(), model.getResourceType(), context, scope); } + /** + * Checks if there are permissions granted for the given {@code model} and {@code scope}. If + * the given {@code scope} is not associated with any permission, the value returned by {@code defaultValue} will + * be returned. + * + * @param model the model + * @param context the context + * @param scope the scope + * @param defaultValue the default value + * @return + */ + boolean hasPermission(ModelRecord model, EvaluationContext context, String scope, Supplier defaultValue) { + return hasPermission(model.getId(), model.getResourceType(), context, scope, defaultValue); + } + boolean hasPermission(String modelId, String resourceType, EvaluationContext context, String scope) { + return hasPermission(modelId, resourceType, context, scope, null); + } + + /** + * Checks if there are permissions granted for the given {@code modelId} and {@code scope}. If + * the given {@code scope} is not associated with any permission, the value returned by {@code defaultValue} will + * be returned. + * + * @param modelId the model id + * @param context the context + * @param scope the scope + * @param defaultValue the default value + * @return + */ + boolean hasPermission(String modelId, String resourceType, EvaluationContext context, String scope, Supplier defaultValue) { if (!root.isAdminSameRealm()) { return false; } @@ -70,9 +102,10 @@ class FineGrainedAdminPermissionEvaluator { resource = new ResourceWrapper(modelId, modelId, new HashSet<>(resourceTypeResource.getScopes()), server); } - Collection permissions = (context == null) ? - root.evaluatePermission(new ResourcePermission(resourceType, resource, resource.getScopes(), server), server) : - root.evaluatePermission(new ResourcePermission(resourceType, resource, resource.getScopes(), server), server, context); + DecisionPermissionCollector decision = (context == null) ? + root.getDecision(new ResourcePermission(resourceType, resource, resource.getScopes(), server), server) : + root.getDecision(new ResourcePermission(resourceType, resource, resource.getScopes(), server), server, context); + Collection permissions = decision.results(); for (Permission permission : permissions) { if (permission.getResourceId().equals(resource.getId())) { @@ -82,6 +115,12 @@ class FineGrainedAdminPermissionEvaluator { } } + if (defaultValue != null) { + if (!decision.isEvaluated(scope)) { + return defaultValue.get(); + } + } + return false; } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java index 5a6cd59310e..f807e0289d2 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java @@ -27,6 +27,7 @@ import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.ResourceServer; import org.keycloak.authorization.model.Scope; import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.authorization.policy.evaluation.DecisionPermissionCollector; import org.keycloak.authorization.policy.evaluation.EvaluationContext; import org.keycloak.common.Profile; import org.keycloak.models.AdminRoles; @@ -323,11 +324,15 @@ class MgmtPermissions implements AdminPermissionEvaluator, AdminPermissionManage return evaluatePermission(permission, resourceServer, new DefaultEvaluationContext(identity, session)); } - public Collection evaluatePermission(List permission, ResourceServer resourceServer) { - return evaluatePermission(permission, resourceServer, new DefaultEvaluationContext(identity, session)); + public DecisionPermissionCollector getDecision(ResourcePermission permission, ResourceServer resourceServer) { + return evaluatePermission(List.of(permission), resourceServer, new DefaultEvaluationContext(identity, session)); } public Collection evaluatePermission(ResourcePermission permission, ResourceServer resourceServer, EvaluationContext context) { + return evaluatePermission(Arrays.asList(permission), resourceServer, context).results(); + } + + public DecisionPermissionCollector getDecision(ResourcePermission permission, ResourceServer resourceServer, EvaluationContext context) { return evaluatePermission(Arrays.asList(permission), resourceServer, context); } @@ -337,14 +342,14 @@ class MgmtPermissions implements AdminPermissionEvaluator, AdminPermissionManage } public boolean evaluatePermission(Resource resource, ResourceServer resourceServer, EvaluationContext context, Scope... scope) { - return !evaluatePermission(Arrays.asList(new ResourcePermission(resource, Arrays.asList(scope), resourceServer)), resourceServer, context).isEmpty(); + return !evaluatePermission(Arrays.asList(new ResourcePermission(resource, Arrays.asList(scope), resourceServer)), resourceServer, context).results().isEmpty(); } - public Collection evaluatePermission(List permissions, ResourceServer resourceServer, EvaluationContext context) { + public DecisionPermissionCollector evaluatePermission(List permissions, ResourceServer resourceServer, EvaluationContext context) { RealmModel oldRealm = session.getContext().getRealm(); try { session.getContext().setRealm(realm); - return authz.evaluators().from(permissions, resourceServer, context).evaluate(resourceServer, null); + return authz.evaluators().from(permissions, resourceServer, context).getDecision(resourceServer, null, DecisionPermissionCollector.class); } finally { session.getContext().setRealm(oldRealm); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionEvaluator.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionEvaluator.java index dfa6a49fddc..0b3a354867a 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionEvaluator.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionEvaluator.java @@ -57,6 +57,23 @@ public interface UserPermissionEvaluator { */ boolean canManage(UserModel user); + /** + * Throws ForbiddenException if {@link #canResetPassword(UserModel)} returns {@code false}. + */ + default void requireResetPassword(UserModel user) { + if (!canResetPassword(user)) { + throw new jakarta.ws.rs.ForbiddenException(); + } + } + + /** + * Returns {@code true} if the caller has permission to {@link org.keycloak.authorization.fgap.AdminPermissionsSchema#RESET_PASSWORD} + * for the given user. Default implementation falls back to {@link #canManage(UserModel)} for backward compatibility. + */ + default boolean canResetPassword(UserModel user) { + return canManage(user); + } + /** * Throws ForbiddenException if {@link #canQuery()} returns {@code false}. */ @@ -158,4 +175,4 @@ public interface UserPermissionEvaluator { boolean isImpersonatable(UserModel user, ClientModel requester); @Deprecated void grantIfNoPermission(boolean grantIfNoPermission); -} +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissions.java index 23cb734b3cf..e5a7f8ba986 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissions.java @@ -434,6 +434,7 @@ class UserPermissions implements UserPermissionEvaluator, UserPermissionManageme map.put("mapRoles", canMapRoles(user)); map.put("manageGroupMembership", canManageGroupMembership(user)); map.put("impersonate", canImpersonate(user)); + map.put("resetPassword", ((UserPermissionEvaluator)this).canResetPassword(user)); return map; } @@ -592,4 +593,4 @@ class UserPermissions implements UserPermissionEvaluator, UserPermissionManageme if (authz == null) return false; return evaluateHierarchy(user, root.groups()::canViewMembers); } -} +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionsV2.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionsV2.java index 549fc4def43..b2e2b274ed1 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionsV2.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/UserPermissionsV2.java @@ -147,6 +147,24 @@ class UserPermissionsV2 extends UserPermissions { return eval.hasPermission(new UserModelRecord(user), null, AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP); } + @Override + public boolean canResetPassword(UserModel user) { + // admin roles has the precedence over permissions + if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) { + return true; + } + + return eval.hasPermission(new UserModelRecord(user), null, AdminPermissionsSchema.RESET_PASSWORD, + () -> eval.hasPermission(new UserModelRecord(user), null, AdminPermissionsSchema.MANAGE)); + } + + @Override + public void requireResetPassword(UserModel user) { + if (!canResetPassword(user)) { + throw new ForbiddenException(); + } + } + // todo this method should be removed and replaced by canImpersonate(user, client); once V1 is removed @Override public boolean canClientImpersonate(ClientModel client, UserModel user) { 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 027809f6134..38729a79df8 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 @@ -27,6 +27,7 @@ import static org.keycloak.authorization.fgap.AdminPermissionsSchema.IMPERSONATE import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MANAGE; import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP; import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MAP_ROLES; +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.RESET_PASSWORD; import static org.keycloak.authorization.fgap.AdminPermissionsSchema.VIEW; import java.util.List; @@ -40,6 +41,7 @@ import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; import org.keycloak.admin.client.resource.UsersResource; import org.keycloak.authorization.fgap.AdminPermissionsSchema; +import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -424,4 +426,42 @@ public class UserResourceTypeEvaluationTest extends AbstractPermissionTest { fail("Expected Exception wasn't thrown."); } catch (ForbiddenException expected) {} } + + @Test + public void testResetPassword() { + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + UserPolicyRepresentation allowMyAdminPermission = createUserPolicy(realm, client, "Only My Admin User Policy", myadmin.getId()); + UserPolicyRepresentation notAllowMyAdminPermission = createUserPolicy(Logic.NEGATIVE, realm, client, "Not Allow My Admin User Policy", myadmin.getId()); + + // allow my admin to see alice only + ScopePermissionRepresentation managePermission = createPermission(client, userAlice.admin().toRepresentation().getId(), usersType, Set.of(VIEW, MANAGE), allowMyAdminPermission); + ScopePermissionRepresentation resetPasswordPermission = createPermission(client, userAlice.admin().toRepresentation().getId(), usersType, Set.of(RESET_PASSWORD), notAllowMyAdminPermission); + List search = realmAdminClient.realm(realm.getName()).users().search(null, -1, -1); + assertEquals(1, search.size()); + assertEquals(userAlice.getUsername(), search.get(0).getUsername()); + + CredentialRepresentation credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue("password"); + + try { + UsersResource users = realmAdminClient.realm(realm.getName()).users(); + users.get(search.get(0).getId()).resetPassword(credential); + fail("Expected Exception wasn't thrown."); + } catch (ForbiddenException expected) { + } + + String permissionId = getScopePermissionsResource(client).findByName(resetPasswordPermission.getName()).getId(); + getScopePermissionsResource(client).findById(permissionId).remove(); + createPermission(client, userAlice.admin().toRepresentation().getId(), usersType, Set.of(RESET_PASSWORD), allowMyAdminPermission); + + UsersResource users = realmAdminClient.realm(realm.getName()).users(); + users.get(search.get(0).getId()).resetPassword(credential); + + permissionId = getScopePermissionsResource(client).findByName(managePermission.getName()).getId(); + getScopePermissionsResource(client).findById(permissionId).remove(); + createPermission(client, userAlice.admin().toRepresentation().getId(), usersType, Set.of(VIEW), allowMyAdminPermission); + + users.get(search.get(0).getId()).resetPassword(credential); + } }