Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand Down Expand Up @@ -50,6 +51,7 @@
import org.keycloak.models.cache.infinispan.entities.CachedClient;
import org.keycloak.models.cache.infinispan.entities.CachedClientRole;
import org.keycloak.models.cache.infinispan.entities.CachedClientScope;
import org.keycloak.models.cache.infinispan.entities.CachedCompositeRoles;
import org.keycloak.models.cache.infinispan.entities.CachedGroup;
import org.keycloak.models.cache.infinispan.entities.CachedRealm;
import org.keycloak.models.cache.infinispan.entities.CachedRealmRole;
Expand Down Expand Up @@ -1507,6 +1509,50 @@ public void addClientScopeToAllClients(RealmModel realm, ClientScopeModel client
invalidationEvents.add(ClientScopeRemovedEvent.create(clientScope.getId(), realm.getId()));
}

public Stream<RoleModel> getCompositeRolesStream(RealmModel realm, Set<String> parentRoleIds) {
String id = realm.getId() + "::" + String.join(",", parentRoleIds.stream().sorted().collect(Collectors.toList()));
CachedCompositeRoles cached = cache.get(id, CachedCompositeRoles.class);
if (cached != null && !cached.getRealm().equals(realm.getId())) {
cached = null;
}
if (cached != null) {
for (String compositeId : cached.getCompositeIds()) {
if (invalidations.contains(compositeId)) {
// If this composite role has been invalidated, it might be faster to fetch it and others from the DB as a batch
cached = null;
break;
}
if (managedRoles.containsKey(compositeId)) {
// If this composite role is already managed, continue
continue;
}
if (getCachedRole(realm, compositeId) == null) {
// If this composite role is not cached, it might be faster to fetch it and others from the DB as a batch
cached = null;
break;
}
}
}
if (cached != null) {
for (String parentId : cached.getParentIds()) {
CachedRole cachedParent = cache.get(parentId, CachedRole.class);
if (cachedParent == null || cachedParent.getRevision() > cached.getRevision()) {
// The parent has been modified or evicted, cache is invalid
cached = null;
break;
}
}
}
if (cached != null) {
return cached.getCompositeIds().stream().map(s -> getRoleById(realm, s)).filter(Objects::nonNull);
}
List<RoleModel> compositeRoles = getRoleDelegate().getCompositeRolesStream(realm, parentRoleIds).toList();
long loaded = cache.getCurrentRevision(id);
cached = new CachedCompositeRoles(loaded, id, new HashSet<>(parentRoleIds), compositeRoles.stream().map(RoleModel::getId).toList(), realm);
cache.addRevisioned(cached, startupRevision);
return compositeRoles.stream();
}

// Don't cache ClientInitialAccessModel for now
@Override
public ClientInitialAccessModel createClientInitialAccessModel(RealmModel realm, int expiration, int count) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2016 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.models.cache.infinispan.entities;

import java.util.List;
import java.util.Set;

import org.keycloak.models.RealmModel;

/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class CachedCompositeRoles extends AbstractRevisioned implements InRealm {

private final Set<String> parentIds;
final protected String realm;
private final List<String> compositeIds;

public CachedCompositeRoles(long revision, String id, Set<String> parentIds, List<String> compositeIds, RealmModel realm) {
super(revision, id);
this.parentIds = parentIds;
this.realm = realm.getId();
this.compositeIds = compositeIds;
}

@Override
public String getRealm() {
return realm;
}

public Set<String> getParentIds() {
return parentIds;
}

public List<String> getCompositeIds() {
return compositeIds;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.keycloak.marshalling.Marshalling;
import org.keycloak.models.cache.infinispan.entities.CachedClient;
import org.keycloak.models.cache.infinispan.entities.CachedClientScope;
import org.keycloak.models.cache.infinispan.entities.CachedCompositeRoles;
import org.keycloak.models.cache.infinispan.entities.CachedGroup;
import org.keycloak.models.cache.infinispan.entities.CachedRole;
import org.keycloak.models.cache.infinispan.entities.Revisioned;
Expand Down Expand Up @@ -44,6 +45,7 @@ void setRole(String role) {
public boolean test(Map.Entry<String, Revisioned> entry) {
Object value = entry.getValue();
return (value instanceof CachedRole cachedRole && cachedRole.getCachedComposites().ids().contains(role)) ||
(value instanceof CachedCompositeRoles cachedCompositeRoles && (cachedCompositeRoles.getCompositeIds().contains(role) || cachedCompositeRoles.getParentIds().contains(role))) ||
(value instanceof CachedGroup cachedGroup && cachedGroup.getCachedRoleMappings().contains(role)) ||
(value instanceof RoleQuery roleQuery && roleQuery.getRoles().contains(role)) ||
(value instanceof CachedClient cachedClient && cachedClient.getScope().contains(role)) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,22 @@ public RoleModel getRoleById(RealmModel realm, String id) {
return adapter;
}

@Override
public Stream<RoleModel> getCompositeRolesStream(RealmModel realm, Set<String> parentRoleIds) {
if (parentRoleIds == null || parentRoleIds.isEmpty()) {
return Stream.empty();
}
// One query fetches the child roles for every parent in the frontier, so a composite-role
// tree expands with a single getChildRolesFromParentIds query per breadth-first level. The
// query hydrates the child RoleEntity rows, so the getRoleById calls below are served from
// the persistence context without extra round-trips.
TypedQuery<RoleEntity> query = em.createNamedQuery("getChildRolesFromParentIds", RoleEntity.class)
.setParameter("parentRoleIds", parentRoleIds);
return closing(query.getResultStream())
.map(roleEntity -> session.roles().getRoleById(realm, roleEntity.getId()))
.filter(Objects::nonNull);
}

@Override
public GroupModel getGroupById(RealmModel realm, String id) {
GroupEntity groupEntity = em.find(GroupEntity.class, id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
@NamedQuery(name="getRoleIdsFromIdList", query="select role.id from RoleEntity role where role.realmId = :realm and role.id in :ids order by role.name ASC"),
@NamedQuery(name="getRoleIdsByNameContainingFromIdList", query="select role.id from RoleEntity role where role.realmId = :realm and lower(role.name) like lower(concat('%',:search,'%')) and role.id in :ids order by role.name ASC"),
@NamedQuery(name="getChildRoles", query="select r from RoleEntity r join CompositeRoleEntity c on r.id = c.childRole.id where c.parentRole.id = :parentRoleId"),
@NamedQuery(name="getChildRolesFromParentIds", query="select distinct r from RoleEntity r join CompositeRoleEntity c on r.id = c.childRole.id where c.parentRole.id in :parentRoleIds"),
})

public class RoleEntity {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
package org.keycloak.storage;

import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.keycloak.common.util.reflections.Types;
Expand Down Expand Up @@ -161,6 +163,32 @@ public Stream<RoleModel> getRolesStream(RealmModel realm, Stream<String> ids, St
return localStorage().getRolesStream(realm, ids, search, first, max);
}

@Override
public Stream<RoleModel> getCompositeRolesStream(RealmModel realm, Set<String> parentRoleIds) {
if (parentRoleIds == null || parentRoleIds.isEmpty()) {
return Stream.empty();
}
// Split local and external IDs. Local IDs are handled by a single batched query; external
// (federated) IDs are resolved individually via getRoleById and then expanded per-role.
Set<String> localIds = parentRoleIds.stream()
.filter(StorageId::isLocalStorage)
.collect(Collectors.toSet());
Set<String> externalIds = parentRoleIds.stream()
.filter(id -> !StorageId.isLocalStorage(id))
.collect(Collectors.toSet());

Stream<RoleModel> localComposites = localIds.isEmpty()
? Stream.empty()
: localStorage().getCompositeRolesStream(realm, localIds);

Stream<RoleModel> externalComposites = externalIds.stream()
.map(id -> getRoleById(realm, id))
.filter(Objects::nonNull)
.flatMap(RoleModel::getCompositesStream);

return Stream.concat(localComposites, externalComposites).distinct();
}

/**
* Obtaining roles from an external role storage is time-bounded. In case the external role storage
* isn't available at least roles from a local storage are returned. For this purpose
Expand Down
25 changes: 25 additions & 0 deletions server-spi/src/main/java/org/keycloak/models/RoleProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
package org.keycloak.models;

import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;

import org.keycloak.provider.Provider;
Expand Down Expand Up @@ -80,6 +82,29 @@ default Stream<RoleModel> getRealmRolesStream(RealmModel realm) {
*/
Stream<RoleModel> getRolesStream(RealmModel realm, Stream<String> ids, String search, Integer first, Integer max);

/**
* Returns the direct composite child roles of all the given parent roles, de-duplicated.
* <p>
* This is the bulk counterpart of {@link RoleModel#getCompositesStream()}: it lets callers expand
* a composite-role tree one breadth-first level at a time with a single lookup per level instead
* of one per role. The default implementation resolves each parent individually; storage-backed
* providers should override it with a batched lookup.
*
* @param realm Realm. Cannot be {@code null}.
* @param parentRoleIds Ids of the roles whose direct composite children should be returned.
* @return Stream of the child roles. Never returns {@code null}.
*/
default Stream<RoleModel> getCompositeRolesStream(RealmModel realm, Set<String> parentRoleIds) {
if (parentRoleIds == null || parentRoleIds.isEmpty()) {
return Stream.empty();
}
return parentRoleIds.stream()
.map(id -> getRoleById(realm, id))
.filter(Objects::nonNull)
.flatMap(RoleModel::getCompositesStream)
.distinct();
}

/**
* Removes given realm role from the given realm.
* @param role Role to be removed.
Expand Down
79 changes: 55 additions & 24 deletions server-spi/src/main/java/org/keycloak/models/utils/RoleUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@

import org.keycloak.models.ClientModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleContainerModel;
import org.keycloak.models.RoleMapperModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.utils.KeycloakSessionUtil;

/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
Expand Down Expand Up @@ -129,13 +131,51 @@ public static boolean hasRoleFromGroup(Stream<GroupModel> groups, RoleModel targ
}

/**
* Recursively expands composite roles into their composite.
* @param role
* @param visited Track roles, which were already visited. Those will be ignored and won't be added to the stream. Besides that,
* the "visited" set itself will be updated as a result of this method call and all the tracked roles will be added to it
* @return Stream of containing all of the composite roles and their components. Never returns {@code null}.
* @param roles
* @return new set with composite roles expanded
*/
private static Stream<RoleModel> expandCompositeRolesStream(RoleModel role, Set<RoleModel> visited) {
public static Set<RoleModel> expandCompositeRoles(Set<RoleModel> roles) {
if (roles.isEmpty()) {
return roles;
}

KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
if (session == null) {
// Outside a Resteasy/session-bound thread there is no KeycloakSession available.
// Fall back to per-role expansion, which works without a session context.
Set<RoleModel> visited = new HashSet<>();
return roles.stream()
.flatMap(role -> expandCompositeRolesWithoutSession(role, visited))
.collect(Collectors.toSet());
}

Set<RoleModel> result = new HashSet<>();
Set<String> enqueued = new HashSet<>();
Set<RoleModel> frontier = roles;

RealmModel realm = realmOf(roles.iterator().next());
while (!frontier.isEmpty()) {
result.addAll(frontier);

Set<String> parentIds = new HashSet<>();
frontier.forEach(role -> {
if (enqueued.add(role.getId())) {
parentIds.add(role.getId());
}
});
if (parentIds.isEmpty()) {
break;
}

frontier = session.roles().getCompositeRolesStream(realm, parentIds)
.filter(role -> !enqueued.contains(role.getId()))
.collect(Collectors.toSet());
}

return result;
}

private static Stream<RoleModel> expandCompositeRolesWithoutSession(RoleModel role, Set<RoleModel> visited) {
Stream.Builder<RoleModel> sb = Stream.builder();

if (!visited.contains(role)) {
Expand All @@ -160,30 +200,14 @@ private static Stream<RoleModel> expandCompositeRolesStream(RoleModel role, Set<
return sb.build();
}


/**
* @param roles
* @return new set with composite roles expanded
*/
public static Set<RoleModel> expandCompositeRoles(Set<RoleModel> roles) {
Set<RoleModel> visited = new HashSet<>();

return roles.stream()
.flatMap(roleModel -> RoleUtils.expandCompositeRolesStream(roleModel, visited))
.collect(Collectors.toSet());
}

/**
* @param roles
* @return stream with composite roles expanded
*/
public static Stream<RoleModel> expandCompositeRolesStream(Stream<RoleModel> roles) {
Set<RoleModel> visited = new HashSet<>();

return roles.flatMap(roleModel -> RoleUtils.expandCompositeRolesStream(roleModel, visited));
return RoleUtils.expandCompositeRoles(roles.collect(Collectors.toSet())).stream();
}


/**
* @param roleMapper
* @return all role mappings for the given mapper with composite roles expanded.
Expand Down Expand Up @@ -212,13 +236,20 @@ public static Set<RoleModel> getDeepUserRoleMappings(UserModel user) {
return expandCompositeRoles(roleMappings);
}


private static void addGroupRoles(GroupModel group, Set<RoleModel> roleMappings) {
roleMappings.addAll(group.getRoleMappingsStream().collect(Collectors.toSet()));
if (group.getParentId() == null) return;
addGroupRoles(group.getParent(), roleMappings);
}

private static RealmModel realmOf(RoleModel role) {
RoleContainerModel container = role.getContainer();
if (container instanceof RealmModel realmModel) {
return realmModel;
}
return ((ClientModel) container).getRealm();
}

public static boolean isRealmRole(RoleModel r) {
return r.getContainer() instanceof RealmModel;
}
Expand Down
Loading