feat(FGAPv2): introduce RESET_PASSWORD scope and evaluation

- Add RESET_PASSWORD to AdminPermissionsSchema.USERS
- Require RESET_PASSWORD in UserResource.resetPassword()
- Expose canResetPassword()/requireResetPassword()
- Implement FGAP v2 deny-overrides + secure-by-default + optional fallback
- Include access.resetPassword for Admin Console

Closes #41901

Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
Signed-off-by: Bagautdino <336373@edu.itmo.ru>
This commit is contained in:
Bagautdino 2025-08-15 11:53:02 +03:00 committed by Pedro Igor
parent 28d3b2dd29
commit d225bce21f
16 changed files with 199 additions and 17 deletions

View File

@ -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,

View File

@ -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.
=== <TODO>
=== 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

View File

@ -42,4 +42,14 @@ public interface Decision<D extends Evaluation> {
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;
}
}

View File

@ -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()));
}
}
}

View File

@ -55,4 +55,9 @@ class FGAPDecision implements Decision<Evaluation> {
public void onComplete(ResourcePermission permission) {
decision.onComplete(permission);
}
@Override
public boolean isEvaluated(String scope) {
return decision.isEvaluated(scope);
}
}

View File

@ -88,10 +88,16 @@ class IterablePermissionEvaluator implements PermissionEvaluator {
@Override
public Collection<Permission> evaluate(ResourceServer resourceServer, AuthorizationRequest request) {
DecisionPermissionCollector decision = getDecision(resourceServer, request, DecisionPermissionCollector.class);
return decision.results();
}
@Override
public <D extends Decision<?>> D getDecision(ResourceServer resourceServer, AuthorizationRequest request, Class<D> decisionType) {
DecisionPermissionCollector decision = new DecisionPermissionCollector(authorizationProvider, resourceServer, request);
evaluate(decision);
return decision.results();
return decisionType.cast(decision);
}
}

View File

@ -34,4 +34,5 @@ public interface PermissionEvaluator {
<D extends Decision> D evaluate(D decision);
Collection<Permission> evaluate(ResourceServer resourceServer, AuthorizationRequest request);
<D extends Decision<?>> D getDecision(ResourceServer resourceServer, AuthorizationRequest request, Class<D> decisionType);
}

View File

@ -60,10 +60,16 @@ public class UnboundedPermissionEvaluator implements PermissionEvaluator {
@Override
public Collection<Permission> evaluate(ResourceServer resourceServer, AuthorizationRequest request) {
DecisionPermissionCollector decision = getDecision(resourceServer, request, DecisionPermissionCollector.class);
return decision.results();
}
@Override
public <D extends Decision<?>> D getDecision(ResourceServer resourceServer, AuthorizationRequest request, Class<D> decisionType) {
DecisionPermissionCollector decision = new DecisionPermissionCollector(authorizationProvider, resourceServer, request);
evaluate(decision);
return decision.results();
return decisionType.cast(decision);
}
}

View File

@ -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<Evaluation>
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;
}
}

View File

@ -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;
}
}
}
}

View File

@ -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<Boolean> 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<Boolean> defaultValue) {
if (!root.isAdminSameRealm()) {
return false;
}
@ -70,9 +102,10 @@ class FineGrainedAdminPermissionEvaluator {
resource = new ResourceWrapper(modelId, modelId, new HashSet<>(resourceTypeResource.getScopes()), server);
}
Collection<Permission> 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<Permission> 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;
}

View File

@ -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<Permission> evaluatePermission(List<ResourcePermission> 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<Permission> 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<Permission> evaluatePermission(List<ResourcePermission> permissions, ResourceServer resourceServer, EvaluationContext context) {
public DecisionPermissionCollector evaluatePermission(List<ResourcePermission> 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);
}

View File

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

View File

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

View File

@ -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) {

View File

@ -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<UserRepresentation> 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);
}
}