Skip to content
Merged
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 @@ -56,5 +56,17 @@ Assign the roles you want to your users and they will only be able to use that s

IMPORTANT: Admins with the `manage-users` role will only be able to assign admin roles to users that they themselves have. So, if an admin has the `manage-users` role but doesn't have the `manage-realm` role, they will not be able to assign this role.

==== Granting admin roles to users

When granting admin roles to users in a realm, you must explicitly assign these roles to the user, either by a direct
role mapping (including roles granted via identity provider mappers) or by assigning the user to a group that has the appropriate role mappings.
This is true even for users in the `master` realm.

Even though you are able to create client protocol mappers for a client to map admin roles to tokens, the admin roles
will be ignored when the user authenticates and will not be included in the token. This is because admin roles are only
used for authorization to access the Admin Console and Admin REST endpoints, and are not intended to be used for
application access control.

Effectively, access to the Admin API is only granted to users with admin roles explicitly assigned through direct role
mappings or group memberships, and not to users with admin roles mapped only through client protocol mappers.

18 changes: 18 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_7_1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ The listing endpoints for default and optional client scopes now return an empty

If you have delegated administrators that assign client scopes using Fine-Grained Admin Permissions, ensure they also have the `manage-clients` realm admin role or a type-level `manage` permission on the Clients resource type.

=== Stricter checks in the Admin API for admin roles granted via protocol mappers

In this release, access to the Admin API is only granted to users with admin roles that are directly assigned to them,
either through direct role assignments or group memberships. Admin roles granted via protocol mappers will no longer grant
Admin API access.

This change was made to enhance security in realm administration delegation scenarios. Developers with limited realm
administration permissions who have access to manage client protocol mappers or client scopes could previously map admin
roles into tokens and gain unintended access to the Admin API. This is no longer possible.

If your existing deployment was relying on admin roles granted via protocol mappers to access the Admin API, you should
update your configuration to directly assign admin roles to users or groups instead.

To verify if your deployment is affected, review any client protocol mappers that map admin roles to tokens. For each mapper
found, ensure the affected users and service accounts have the required admin roles directly assigned either through direct role assignments or
through group memberships. You can verify this in the Keycloak Admin Console by checking the user's role assignments and
group memberships in the target realm.

// ------------------------ Notable changes ------------------------ //
== Notable changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ public class AdminRoles {
}

public static boolean isAdminRole(RoleModel role) {
if (role == null) {
return false;
}

if (!ALL_ROLES.contains(role.getName())) {
return false;
}
Expand Down Expand Up @@ -123,4 +127,18 @@ private static boolean isAdminRole(RoleModel role, Set<String> visited) {
}
return role.getCompositesStream().anyMatch(child -> isAdminRole(child, visited));
}

public static boolean containsAdminRole(RoleModel role) {
return containsAdminRole(role, new HashSet<>());
}

private static boolean containsAdminRole(RoleModel role, Set<String> visited) {
if (isAdminRole(role)) {
return true;
}
if (role == null || !role.isComposite() || !visited.add(role.getId())) {
return false;
}
return role.getCompositesStream().anyMatch(r -> containsAdminRole(r, visited));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
Expand Down Expand Up @@ -61,6 +62,7 @@
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.deployment.DeployedConfigurationsManager;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
Expand All @@ -86,6 +88,7 @@
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AccessToken.Access;
import org.keycloak.representations.idm.CertificateRepresentation;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
Expand Down Expand Up @@ -1124,6 +1127,38 @@ public static String buildRoleQualifier(String clientId, String roleName) {
return clientId + CLIENT_ROLE_SEPARATOR + roleName;
}

public static RoleModel getRoleByName(RealmModel realm, String clientId, String name) {
if (clientId == null) {
return realm.getRole(name);
} else {
ClientModel client = realm.getClientByClientId(clientId);

if (client == null) {
return null;
}

return client.getRole(name);
}
}

public static void removeTransientAdminRoles(RealmModel realm, String clientId, UserModel user, Access access) {
if (access == null || access.getRoles() == null) {
return;
}

Set<String> roles = access.getRoles();
Iterator<String> roleIterator = roles.iterator();

while (roleIterator.hasNext()) {
String role = roleIterator.next();
RoleModel adminRole = getRoleByName(realm, clientId, role);

if (AdminRoles.containsAdminRole(adminRole) && !user.hasRole(adminRole)) {
roleIterator.remove();
}
}
}

/**
* Check to see if a flow is currently in use
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import org.keycloak.models.UserSessionProvider;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessToken.Access;
import org.keycloak.representations.IDToken;
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.services.ErrorResponseException;
Expand All @@ -52,6 +53,8 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import static org.keycloak.models.utils.KeycloakModelUtils.removeTransientAdminRoles;

/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
Expand Down Expand Up @@ -153,33 +156,23 @@ public KeycloakIdentity(IDToken token, KeycloakSession keycloakSession, RealmMod
this.accessToken = new TokenManager().createClientAccessToken(keycloakSession, realm, client, userSession.getUser(), userSession, clientSessionCtx, clientSessionCtx.isOfflineTokenRequested());
}

AccessToken.Access realmAccess = this.accessToken.getRealmAccess();

if (realmAccess != null) {
attributes.put("kc.realm.roles", realmAccess.getRoles());
}

Map<String, AccessToken.Access> resourceAccess = this.accessToken.getResourceAccess();

if (resourceAccess != null) {
resourceAccess.forEach((clientId, access) -> attributes.put("kc.client." + clientId + ".roles", access.getRoles()));
}

ClientModel clientModel = getTargetClient();
UserModel clientUser = null;

if (clientModel != null && clientModel.isServiceAccountsEnabled()) {
clientUser = this.keycloakSession.users().getServiceAccount(clientModel);
}

UserModel userSession = getUserFromToken();
UserModel user = getUserFromToken();

this.resourceServer = clientUser != null && userSession.getId().equals(clientUser.getId());
addRolesAsAttributes(this.accessToken, realm, user, attributes);

this.resourceServer = clientUser != null && user.getId().equals(clientUser.getId());

if (resourceServer) {
this.id = clientModel.getId();
} else {
this.id = userSession.getId();
this.id = user.getId();
}

this.attributes = Attributes.from(attributes);
Expand Down Expand Up @@ -237,31 +230,21 @@ public KeycloakIdentity(AccessToken accessToken, KeycloakSession keycloakSession
}
}

AccessToken.Access realmAccess = accessToken.getRealmAccess();

if (realmAccess != null) {
attributes.put("kc.realm.roles", realmAccess.getRoles());
}

Map<String, AccessToken.Access> resourceAccess = accessToken.getResourceAccess();

if (resourceAccess != null) {
resourceAccess.forEach((clientId, access) -> attributes.put("kc.client." + clientId + ".roles", access.getRoles()));
}

ClientModel clientModel = getTargetClient();
UserModel clientUser = null;

if (clientModel != null && clientModel.isServiceAccountsEnabled()) {
clientUser = this.keycloakSession.users().getServiceAccount(clientModel);
}

UserModel userSession = getUserFromToken();
if (userSession == null) {
UserModel user = getUserFromToken();
if (user == null) {
throw new IllegalArgumentException("User from token not found");
}

if (clientUser != null && userSession.getId().equals(clientUser.getId())) {
addRolesAsAttributes(accessToken, realm, user, attributes);

if (clientUser != null && user.getId().equals(clientUser.getId())) {
AuthorizationProvider provider = keycloakSession.getProvider(AuthorizationProvider.class);
StoreFactory storeFactory = provider.getStoreFactory();
ResourceServerStore resourceServerStore = storeFactory.getResourceServerStore();
Expand All @@ -273,7 +256,7 @@ public KeycloakIdentity(AccessToken accessToken, KeycloakSession keycloakSession
if (resourceServer) {
this.id = clientModel.getId();
} else {
this.id = userSession.getId();
this.id = user.getId();
}
} catch (Exception e) {
throw new RuntimeException("Error while reading attributes from security token.", e);
Expand Down Expand Up @@ -335,4 +318,22 @@ private UserModel getUserFromToken() {
}
return userSession.getUser();
}

private void addRolesAsAttributes(AccessToken accessToken, RealmModel realm, UserModel user, Map<String, Collection<String>> attributes) {
Access realmAccess = accessToken.getRealmAccess();

if (realmAccess != null) {
removeTransientAdminRoles(realm, null, user, realmAccess);
attributes.put("kc.realm.roles", realmAccess.getRoles());
}

Map<String, Access> resourceAccess = accessToken.getResourceAccess();

if (resourceAccess != null) {
resourceAccess.forEach((clientId, access) -> {
removeTransientAdminRoles(realm, clientId, user, access);
attributes.put("kc.client." + clientId + ".roles", access.getRoles());
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.keycloak.services.resources.admin;

import java.util.Map;
import java.util.Map.Entry;

import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.token.TokenPostProcessor;
import org.keycloak.protocol.oidc.token.TokenPostProcessorContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessToken.Access;

import static org.keycloak.models.utils.KeycloakModelUtils.removeTransientAdminRoles;

/**
* A {@link TokenPostProcessor} that removes from access tokens any admin role not explicitly granted to the
* subject.
*/
public class AdminRoleTokenPostProcessor implements TokenPostProcessor {

private final KeycloakSession session;

public AdminRoleTokenPostProcessor(KeycloakSession session) {
this.session = session;
}

@Override
public void process(TokenPostProcessorContext context) {
ClientSessionContext clientSessionCtx = context.clientSessionCtx();
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
UserSessionModel userSession = clientSession.getUserSession();
UserModel user = userSession.getUser();
RealmModel realm = session.getContext().getRealm();
AccessToken accessToken = context.accessToken();

removeTransientAdminRoles(realm, null, user, accessToken.getRealmAccess());

Map<String, Access> resourceAccess = accessToken.getResourceAccess();

for (Entry<String, Access> access : resourceAccess.entrySet()) {
removeTransientAdminRoles(realm, access.getKey(), user, access.getValue());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.keycloak.services.resources.admin;

import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.token.TokenPostProcessor;
import org.keycloak.protocol.oidc.token.TokenPostProcessorFactory;

public class AdminRoleTokenPostProcessorFactory implements TokenPostProcessorFactory {

@Override
public TokenPostProcessor create(KeycloakSession session) {
return new AdminRoleTokenPostProcessor(session);
}

@Override
public String getId() {
return "admin-role-token-post-processor";
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorsPostProcessorFactory
org.keycloak.organization.protocol.mappers.oidc.OrganizationTokenPostProcessorFactory
org.keycloak.protocol.oid4vc.refresh.OID4VCITokenPostProcessorProviderFactory
org.keycloak.protocol.oid4vc.refresh.OID4VCITokenPostProcessorProviderFactory
org.keycloak.services.resources.admin.AdminRoleTokenPostProcessorFactory
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,19 @@ protected void grantMasterRealmManagementRole(String targetRealmName, String mas
}

protected void grantRealmManagementRole(RealmResource realm, String userName, String adminRole) {
List<UserRepresentation> users = realm.users().search(userName, true);
assertEquals(1, users.size());
UserResource userApi = realm.users().get(users.get(0).getId());
ClientRepresentation mgmtClient = realm.clients().findByClientId(Constants.REALM_MANAGEMENT_CLIENT_ID).get(0);
ClientResource mgmtClientApi = realm.clients().get(mgmtClient.getId());
RoleRepresentation role = mgmtClientApi.roles().get(adminRole).toRepresentation();
UserResource userApi;
ClientRepresentation mgmtClient;
RoleRepresentation role;
try {
List<UserRepresentation> users = realm.users().search(userName, true);
assertEquals(1, users.size());
userApi = realm.users().get(users.get(0).getId());
mgmtClient = realm.clients().findByClientId(Constants.REALM_MANAGEMENT_CLIENT_ID).get(0);
ClientResource mgmtClientApi = realm.clients().get(mgmtClient.getId());
role = mgmtClientApi.roles().get(adminRole).toRepresentation();
} catch (Exception e) {
throw new IllegalStateException("Unexpected exception", e);
}
userApi.roles().clientLevel(mgmtClient.getId()).add(List.of(role));
}

Expand All @@ -95,6 +102,7 @@ protected UserRepresentation createUser(RealmResource realm, String username) {
.email(username.concat("@keycloak.org"))
.firstName("First")
.lastName("Last")
.password("password")
.enabled(true).build();

try (Response response = realm.users().create(user)) {
Expand Down Expand Up @@ -127,16 +135,21 @@ protected void grantRealmRole(RealmResource realm, UserRepresentation user, Stri
}

protected void runAs(String realm, String username, Consumer<Keycloak> consumer) {
Keycloak userClient = createAdminClient(realm, username);
runAs(realm, "admin-cli", username, consumer);
}

protected void runAs(String realm, String clientId, String username, Consumer<Keycloak> consumer) {
Keycloak userClient = createAdminClient(realm, clientId, username);
consumer.accept(userClient);
}

private Keycloak createAdminClient(String realmName, String username) {
private Keycloak createAdminClient(String realmName, String clientId, String username) {
return adminClientFactory.create()
.realm(realmName)
.clientId("admin-cli")
.clientId(clientId)
.username(username)
.password("password")
.autoClose()
.build();
}

Expand Down
Loading
Loading