Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public void evaluate(Evaluation evaluation) {
for (String client : representation.getClients()) {
ClientModel clientModel = realm.getClientById(client);
if (clientModel != null) {
if (context.getAttributes().containsValue("kc.client.id", clientModel.getClientId())) {
if (context.getAttributes().containsValue(EvaluationContext.CLIENT_ID_ATTRIBUTE, clientModel.getClientId())) {
evaluation.grant();
logger.debugf("Client policy %s matched with client %s and was granted", evaluation.getPolicy().getName(), clientModel.getClientId());
return;
Expand Down
11 changes: 11 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_8_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ The token introspection endpoint previously set the `act.sub` field to the imper

If you have resource servers that parse `act.sub` from introspection responses and expect a username, update them to read `act.preferred_username` instead.

=== Token exchange delegation now requires FGAP V2 permissions

The experimental token exchange delegation feature no longer uses the `impersonation` role from the `realm-management` client to authorize delegation. Instead, delegation is controlled exclusively through link:{adminguide_link}#_fine_grained_permissions[Fine-grained admin permissions version 2] (FGAP V2) using the new `delegate` scope on the Users resource type and the `delegate-members` scope on the Groups resource type.

If you were previously using delegation with the `impersonation` role, you need to:

. Enable Admin Permissions (FGAP V2) on your realm.
. Create a User permission with the `delegate` scope and assign a policy targeting the administrators or service accounts that should be allowed to delegate. Alternatively, create a Group permission with the `delegate-members` scope to grant delegation for members of a specific group.

FGAP V1 does not support delegation permissions.

=== Email is no longer marked as verified by other flows

Previously, some flows marked the email of a user as verified even though their purpose was not email verification.
Expand Down
30 changes: 27 additions & 3 deletions docs/guides/securing-apps/token-exchange.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -352,14 +352,36 @@ bin/kc.[sh|bat] start --features=token-exchange-delegation,parameterized-scopes

=== Admin delegation

Admin delegation allows a user (subject) to delegate their token to an administrator. The client requests the `delegation` scope with the administrator's identifier as a parameter using the https://datatracker.ietf.org/doc/html/rfc9396[parameterized scopes] syntax.
Admin delegation allows a user (subject) to delegate their token to an administrator (actor). The client requests the `delegation` scope with the administrator's identifier as a parameter using the https://datatracker.ietf.org/doc/html/rfc9396[parameterized scopes] syntax.

When the feature is enabled, a `delegation` client scope is automatically created in new realms. Clients that want to use delegation must add the `delegation` scope to their optional client scopes in the Admin Console.

==== Granting delegation permission

Delegation permissions are managed through link:{adminguide_link}#_fine_grained_permissions[Fine-grained admin permissions version 2] (FGAP V2). Unlike impersonation, delegation does not use admin roles. Instead, you configure FGAP permissions to control which administrators can be delegated to and for which users.

===== Granting delegation to all users

. Go to *Realm Settings* -> *Admin Permissions* and enable Admin Permissions.
. Navigate to *Admin Permissions* -> *Users*.
. Create a new permission on the *Users* resource type.
. Add the *delegate* scope.
. Assign a policy that grants this permission.

NOTE: To restrict delegation to specific users, select individual users when creating the permission. To restrict which administrators or service accounts can delegate, use a more specific policy (for example, a *User* or *Client* policy).

===== Granting delegation to members of a specific group

. Navigate to *Admin Permissions* -> *Groups*.
. Select the target group.
. Create a new permission on the group.
. Add the *delegate-members* scope.
. Assign a policy targeting the administrator or service account allowed to delegate.

==== How it works

. The client requests the `delegation` scope with the administrator's (actor) identifier as a parameter, for example `scope=openid delegation:admin`.
. {project_name} validates that the specified administrator (actor) exists and has the `impersonation` role from the `realm-management` client.
. {project_name} validates that the specified administrator (actor) exists and has the `delegate` permission for the user (subject) through FGAP V2.
. The user (subject) is presented with a consent screen to approve the delegation.
. If the user consents and validation passes, the issued token includes the `may_act` claim with the actor's user ID as the `sub`:
+
Expand All @@ -377,7 +399,9 @@ When the feature is enabled, a `delegation` client scope is automatically create

The `may_act` claim declares that the specified administrator (actor) is authorized to act on behalf of the user (subject). The `sub` inside `may_act` contains the actor's user ID, consistent with {project_name}'s standard `sub` claim semantics. An external Security Token Service (STS) receiving this token can verify the delegation authorization directly from the claim without querying back to {project_name}.

If the administrator (actor) does not have impersonation permission, the delegation scope is silently dropped and the `may_act` claim is not included in the token. The consent for delegation is always required and is not stored permanently - the user must approve delegation each login session.
If the administrator (actor) does not have the delegation permission, the delegation scope is silently dropped and the `may_act` claim is not included in the token. The consent for delegation is always required and is not stored permanently - the user must approve delegation each login session.

NOTE: Delegation permissions require FGAP V2. FGAP V1 does not support delegation.

==== Adding additional claims to `may_act`

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.keycloak.migration.migrators;

import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;

public class MigrateTo26_8_0 extends RealmMigration {

public static final ModelVersion VERSION = new ModelVersion("26.8.0");

@Override
public ModelVersion getVersion() {
return VERSION;
}

@Override
public void migrateRealm(KeycloakSession session, RealmModel realm) {
AdminPermissionsSchema.SCHEMA.addResourceTypeScope(session, realm, AdminPermissionsSchema.USERS_RESOURCE_TYPE, AdminPermissionsSchema.DELEGATE);
AdminPermissionsSchema.SCHEMA.addResourceTypeScope(session, realm, AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, AdminPermissionsSchema.DELEGATE_MEMBERS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import org.keycloak.migration.migrators.MigrateTo26_6_1;
import org.keycloak.migration.migrators.MigrateTo26_6_2;
import org.keycloak.migration.migrators.MigrateTo26_7_0;
import org.keycloak.migration.migrators.MigrateTo26_8_0;
import org.keycloak.migration.migrators.MigrateTo2_0_0;
import org.keycloak.migration.migrators.MigrateTo2_1_0;
import org.keycloak.migration.migrators.MigrateTo2_2_0;
Expand Down Expand Up @@ -138,6 +139,7 @@ public class DefaultMigrationManager implements MigrationManager {
new MigrateTo26_6_1(),
new MigrateTo26_6_2(),
new MigrateTo26_7_0(),
new MigrateTo26_8_0(),
};

private final KeycloakSession session;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,18 @@ public class AdminPermissionsSchema extends AuthorizationSchema {

// user specific scopes
public static final String IMPERSONATE = "impersonate";
public static final String DELEGATE = "delegate";
public static final String RESET_PASSWORD = "reset-password";

public static final String MANAGE_GROUP_MEMBERSHIP = "manage-group-membership";

// group specific scope for delegation
public static final String DELEGATE_MEMBERS = "delegate-members";

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_MEMBERSHIP_OF_MEMBERS, MANAGE_MEMBERS, VIEW_MEMBERS, IMPERSONATE_MEMBERS));
public static final ResourceType GROUPS = new ResourceType(GROUPS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, MANAGE_MEMBERSHIP, MANAGE_MEMBERSHIP_OF_MEMBERS, MANAGE_MEMBERS, VIEW_MEMBERS, IMPERSONATE_MEMBERS, DELEGATE_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, RESET_PASSWORD), Map.of(VIEW, Set.of(VIEW_MEMBERS), MANAGE, Set.of(MANAGE_MEMBERS), IMPERSONATE, Set.of(IMPERSONATE_MEMBERS), MANAGE_GROUP_MEMBERSHIP, Set.of(MANAGE_MEMBERSHIP_OF_MEMBERS)), GROUPS.getType());
public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, DELEGATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP, RESET_PASSWORD), Map.of(VIEW, Set.of(VIEW_MEMBERS), MANAGE, Set.of(MANAGE_MEMBERS), IMPERSONATE, Set.of(IMPERSONATE_MEMBERS), DELEGATE, Set.of(DELEGATE_MEMBERS), MANAGE_GROUP_MEMBERSHIP, Set.of(MANAGE_MEMBERSHIP_OF_MEMBERS)), GROUPS.getType());
public static final ResourceType ORGANIZATIONS = new ResourceType(ORGANIZATIONS_RESOURCE_TYPE, Set.of(MANAGE, VIEW));
private static final String SKIP_EVALUATION = "kc.authz.fgap.skip";
public static final AdminPermissionsSchema SCHEMA = new AdminPermissionsSchema();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
*/
public interface EvaluationContext {

String REALM_NAME_ATTRIBUTE = "kc.realm.name";
String CLIENT_ID_ATTRIBUTE = "kc.client.id";

/**
* Returns the {@link Identity} that represents an entity (person or non-person) to which the permissions must be granted, or not.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ protected Map<String, Collection<String>> getBaseAttributes() {
attributes.put("kc.client.user_agent", userAgents);
}

attributes.put("kc.realm.name", Arrays.asList(this.keycloakSession.getContext().getRealm().getName()));
attributes.put(REALM_NAME_ATTRIBUTE, Arrays.asList(this.keycloakSession.getContext().getRealm().getName()));

if (claims != null) {
for (Entry<String, List<String>> entry : claims.entrySet()) {
Expand All @@ -83,7 +83,7 @@ protected Map<String, Collection<String>> getBaseAttributes() {
AccessToken accessToken = KeycloakIdentity.class.cast(this.identity).getAccessToken();

if (accessToken != null) {
attributes.put("kc.client.id", Arrays.asList(accessToken.getIssuedFor()));
attributes.put(CLIENT_ID_ATTRIBUTE, Arrays.asList(accessToken.getIssuedFor()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ private Optional<IntermediaryScopeRepresentation> getMatchingClientScope(UserMod
} catch (InvalidScopeParameterException e) {
logger.warnf("Invalid scope parameter for '%s': %s", clientScopeModel.getName(), e.getMessage());
return Optional.empty();
} catch (UnsupportedOperationException e) {
logger.warnf("Unsupported scope type operation for '%s': %s", clientScopeModel.getName(), e.getMessage());
return Optional.empty();
}
return Optional.of(new IntermediaryScopeRepresentation(clientScopeModel, paramValue, requestScope));
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ public void validateParameterWithUser(@Nonnull UserModel currentUser, @Nonnull C
}
RealmModel realm = scope.getRealm();
AdminPermissionEvaluator evaluator = AdminPermissions.evaluator(session, realm, realm, targetUser);
if (!evaluator.users().canImpersonate(currentUser, null)) {
throw new InvalidScopeParameterException(String.format("User '%s' cannot be impersonated by the administrator '%s' in realm '%s'",
currentUser.getUsername(), targetUser.getUsername(), realm.getName()));
if (!evaluator.users().canDelegate(currentUser)) {
throw new InvalidScopeParameterException(String.format("Administrator '%s' is not allowed to delegate as user '%s' in realm '%s'",
targetUser.getUsername(), currentUser.getUsername(), realm.getName()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ public boolean canExchangeTo(ClientModel authorizedClient, ClientModel to, Acces
@Override
public Map<String, Collection<String>> getBaseAttributes() {
Map<String, Collection<String>> attributes = super.getBaseAttributes();
attributes.put("kc.client.id", Arrays.asList(authorizedClient.getClientId()));
attributes.put(CLIENT_ID_ATTRIBUTE, Arrays.asList(authorizedClient.getClientId()));
return attributes;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ public boolean canExchangeTo(ClientModel authorizedClient, IdentityProviderModel
@Override
public Map<String, Collection<String>> getBaseAttributes() {
Map<String, Collection<String>> attributes = super.getBaseAttributes();
attributes.put("kc.client.id", Arrays.asList(authorizedClient.getClientId()));
attributes.put(CLIENT_ID_ATTRIBUTE, Arrays.asList(authorizedClient.getClientId()));
return attributes;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@

import java.util.Map;

import jakarta.ws.rs.ForbiddenException;

import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ImpersonationConstants;
import org.keycloak.models.UserModel;

/**
Expand Down Expand Up @@ -62,7 +63,7 @@ public interface UserPermissionEvaluator {
*/
default void requireResetPassword(UserModel user) {
if (!canResetPassword(user)) {
throw new jakarta.ws.rs.ForbiddenException();
throw new ForbiddenException();
}
}

Expand Down Expand Up @@ -129,14 +130,35 @@ default boolean canResetPassword(UserModel user) {
boolean canImpersonate();

/**
* Returns {@code true} if the caller has the {@link ImpersonationConstants#IMPERSONATION_ROLE}.
* Returns {@code true} if the caller has the {@link AdminRoles#IMPERSONATION} role.
* <p/>
* NOTE: If requester is provided, it's clientId is added to evaluation context.
* NOTE: If requester is provided, its clientId is added to evaluation context.
* <p/>
* Or if it has a permission to {@link AdminPermissionsSchema#IMPERSONATE} the user.
*/
boolean canImpersonate(UserModel user, ClientModel requester);

/**
* Throws ForbiddenException if {@link #canDelegate(UserModel)} returns {@code false}.
*/
default void requireDelegate(UserModel user) {
if (!canDelegate(user)) {
throw new ForbiddenException();
}
}

/**
* Returns {@code true} if the caller has a permission to {@link AdminPermissionsSchema#DELEGATE} users.
*/
default boolean canDelegate() {
return canDelegate((UserModel) null);
}

/**
* Returns {@code true} if the caller has a permission to {@link AdminPermissionsSchema#DELEGATE} the user.
*/
boolean canDelegate(UserModel user);

/**
* Returns Map with information what access the caller for the provided user has.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.authorization.Permission;

import static org.keycloak.authorization.policy.evaluation.EvaluationContext.CLIENT_ID_ATTRIBUTE;

/**
* Manages default policies for all users.
*
Expand Down Expand Up @@ -342,7 +344,7 @@ public boolean canClientImpersonate(ClientModel client, UserModel user) {
@Override
public Map<String, Collection<String>> getBaseAttributes() {
Map<String, Collection<String>> attributes = super.getBaseAttributes();
attributes.put("kc.client.id", Arrays.asList(client.getClientId()));
attributes.put(CLIENT_ID_ATTRIBUTE, Arrays.asList(client.getClientId()));
return attributes;
}

Expand Down Expand Up @@ -395,7 +397,7 @@ public boolean isImpersonatable(UserModel user, ClientModel requester) {
if (requester != null) {
// make sure the requesting client id is available from the context as we are using a user identity that does not rely on token claims
additionalClaims = new HashMap<>();
additionalClaims.put("kc.client.id", Arrays.asList(requester.getClientId()));
additionalClaims.put(CLIENT_ID_ATTRIBUTE, Arrays.asList(requester.getClientId()));
}

return hasPermission(new DefaultEvaluationContext(new UserModelIdentity(root.realm, user), additionalClaims, session), USER_IMPERSONATED_SCOPE);
Expand Down Expand Up @@ -426,6 +428,11 @@ public void requireImpersonate(UserModel user) {
}
}

@Override
public boolean canDelegate(UserModel user) {
throw new UnsupportedOperationException("Delegation permissions are only supported with FGAP V2");
}

@Override
public Map<String, Boolean> getAccess(UserModel user) {
Map<String, Boolean> map = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import org.keycloak.models.UserModel;
import org.keycloak.services.resources.admin.fgap.ModelRecord.UserModelRecord;

import static org.keycloak.authorization.policy.evaluation.EvaluationContext.CLIENT_ID_ATTRIBUTE;

class UserPermissionsV2 extends UserPermissions {

private final FineGrainedAdminPermissionEvaluator eval;
Expand Down Expand Up @@ -125,11 +127,23 @@ public boolean canImpersonate(UserModel user, ClientModel requester) {
}

DefaultEvaluationContext context = requester == null ? null :
new DefaultEvaluationContext(new UserModelIdentity(root.realm, user), Map.of("kc.client.id", List.of(requester.getClientId())), session);
new DefaultEvaluationContext(new UserModelIdentity(root.realm, user), Map.of(CLIENT_ID_ATTRIBUTE, List.of(requester.getClientId())), session);

return eval.hasPermission(new UserModelRecord(user), context, AdminPermissionsSchema.IMPERSONATE);
}

@Override
public boolean canDelegate(UserModel user) {
return eval.hasPermission(new UserModelRecord(user), null, AdminPermissionsSchema.DELEGATE);
}
Comment thread
mabartos marked this conversation as resolved.

@Override
public Map<String, Boolean> getAccess(UserModel user) {
Map<String, Boolean> map = super.getAccess(user);
map.put("delegate", canDelegate(user));
return map;
}

@Override
public boolean canMapRoles(UserModel user) {
if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) {
Expand Down
Loading
Loading