Support partial evaluation for the group resource type

Closes #38273

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-03-19 16:55:47 -03:00
parent f2628a9615
commit 1c57035d41
18 changed files with 678 additions and 247 deletions

View File

@ -95,7 +95,6 @@ public class RealmCacheManager extends CacheManager {
public void groupQueriesInvalidations(String realmId, Set<String> invalidations) {
invalidations.add(RealmCacheSession.getGroupsQueryCacheKey(realmId));
invalidations.add(RealmCacheSession.getTopGroupsQueryCacheKey(realmId));
addInvalidations(GroupListPredicate.create().realm(realmId), invalidations);
}

View File

@ -632,10 +632,6 @@ public class RealmCacheSession implements CacheRealmProvider {
return client + "." + (defaultScope ? SCOPE_KEY_DEFAULT : SCOPE_KEY_OPTIONAL) + ".clientscopes";
}
static String getTopGroupsQueryCacheKey(String realm) {
return realm + ".top.groups";
}
static String getGroupByNameCacheKey(String realm, String parentId, String name) {
if (parentId != null) {
return realm + ".group." + parentId + "." + name;
@ -1096,57 +1092,6 @@ public class RealmCacheSession implements CacheRealmProvider {
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer first, Integer max) {
String cacheKey = getTopGroupsQueryCacheKey(realm.getId());
if (hasInvalidation(realm, cacheKey)) {
return getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max);
}
GroupListQuery query = cache.get(cacheKey, GroupListQuery.class);
String searchKey = Optional.ofNullable(search).orElse("") + "." + Optional.ofNullable(first).orElse(-1) + "." + Optional.ofNullable(max).orElse(-1);
Set<String> cached;
if (Objects.isNull(query)) {
// not cached yet
Long loaded = cache.getCurrentRevision(cacheKey);
cached = getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max).map(GroupModel::getId).collect(Collectors.toSet());
query = new GroupListQuery(loaded, cacheKey, realm, searchKey, cached);
logger.tracev("adding realm getTopLevelGroups cache miss: realm {0} key {1}", realm.getName(), cacheKey);
cache.addRevisioned(query, startupRevision);
} else {
logger.tracev("getTopLevelGroups cache hit: {0}", realm.getName());
cached = query.getGroups(searchKey);
if (hasInvalidation(realm, cacheKey) || cached == null) {
// there is a cache entry, but the current search is not yet cached
cache.invalidateObject(cacheKey);
Long loaded = cache.getCurrentRevision(cacheKey);
cached = getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max).map(GroupModel::getId).collect(Collectors.toSet());
query = new GroupListQuery(loaded, cacheKey, realm, searchKey, cached, query);
logger.tracev("adding realm getTopLevelGroups search cache miss: realm {0} key {1}", realm.getName(), searchKey);
cache.addRevisioned(query, cache.getCurrentCounter());
}
}
AtomicBoolean invalidate = new AtomicBoolean(false);
Stream<GroupModel> groups = cached.stream()
.map((id) -> session.groups().getGroupById(realm, id))
.takeWhile(group -> {
if (Objects.isNull(group)) {
invalidate.set(true);
return false;
}
return true;
})
.sorted(GroupModel.COMPARE_BY_NAME);
if (!invalidate.get()) {
return groups;
}
invalidations.add(cacheKey);
return getGroupDelegate().getTopLevelGroupsStream(realm, search, exact, first, max);
}

View File

@ -17,6 +17,11 @@
package org.keycloak.models.jpa;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.ClientModel;
import org.keycloak.models.GroupModel;
@ -136,17 +141,32 @@ public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> {
@Override
public Stream<GroupModel> getSubGroupsStream(String search, Boolean exact, Integer firstResult, Integer maxResults) {
TypedQuery<String> query;
if (Boolean.TRUE.equals(exact)) {
query = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
} else {
query = em.createNamedQuery("getGroupIdsByParentAndNameContaining", String.class);
}
query.setParameter("realm", realm.getId())
.setParameter("parent", group.getId())
.setParameter("search", search == null ? "" : search);
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
return closing(paginateQuery(query, firstResult, maxResults).getResultStream()
queryBuilder.select(root.get("id"));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.add(builder.equal(root.get("parentId"), group.getId()));
search = search == null ? "" : search;
if (Boolean.TRUE.equals(exact)) {
predicates.add(builder.like(root.get("name"), search));
} else {
predicates.add(builder.like(builder.lower(root.get("name")), builder.lower(builder.literal("%" + search + "%"))));
}
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return closing(paginateQuery(em.createQuery(queryBuilder), firstResult, maxResults).getResultStream()
.map(realm::getGroupById)
// In concurrent tests, the group might be deleted in another thread, therefore, skip those null values.
.filter(Objects::nonNull)
@ -155,10 +175,22 @@ public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> {
@Override
public Long getSubGroupsCount() {
return em.createNamedQuery("getGroupCountByParent", Long.class)
.setParameter("realm", realm.getId())
.setParameter("parent", group.getId())
.getSingleResult();
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<Long> queryBuilder = builder.createQuery(Long.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
queryBuilder.select(builder.count(root.get("id")));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.add(builder.equal(root.get("parentId"), group.getId()));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
return em.createQuery(queryBuilder).getSingleResult();
}
@Override

View File

@ -43,6 +43,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.Session;
import org.jboss.logging.Logger;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
@ -519,16 +520,29 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public GroupModel getGroupByName(RealmModel realm, GroupModel parent, String name) {
TypedQuery<String> query = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
query.setParameter("search", name);
query.setParameter("realm", realm.getId());
query.setParameter("parent", parent != null ? parent.getId() : GroupEntity.TOP_PARENT_ID);
List<String> entities = query.getResultList();
if (entities.isEmpty()) return null;
if (entities.size() > 1) throw new IllegalStateException("Should not be more than one Group with same name");
String id = query.getResultList().get(0);
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
return session.groups().getGroupById(realm, id);
queryBuilder.select(root.get("id"));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.add(builder.equal(root.get("parentId"), parent != null ? parent.getId() : GroupEntity.TOP_PARENT_ID));
predicates.add(builder.like(root.get("name"), name));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, null, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
List<String> groups = em.createQuery(queryBuilder).getResultList();
if (groups.isEmpty()) return null;
if (groups.size() > 1) throw new IllegalStateException("Should not be more than one Group with same name");
return session.groups().getGroupById(realm, groups.get(0));
}
@Override
@ -560,10 +574,24 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public Stream<GroupModel> getGroupsStream(RealmModel realm) {
return closing(em.createNamedQuery("getGroupIdsByRealm", String.class)
.setParameter("realm", realm.getId())
.getResultStream())
.map(g -> session.groups().getGroupById(realm, g));
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
queryBuilder.select(root.get("id"));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, null, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return closing(em.createQuery(queryBuilder).getResultStream())
.map(g -> session.groups().getGroupById(realm, g))
.filter(Objects::nonNull);
}
@Override
@ -575,13 +603,26 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
return Stream.empty();
}
TypedQuery<String> query = em.createNamedQuery("getGroupIdsByNameContainingFromIdList", String.class)
.setParameter("realm", realm.getId())
.setParameter("search", search)
.setParameter("ids", idsList);
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
return closing(paginateQuery(query, first, max).getResultStream())
.map(g -> session.groups().getGroupById(realm, g)).filter(Objects::nonNull);
queryBuilder.select(root.get("id"));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.add(builder.like(builder.lower(root.get("name")), builder.lower(builder.literal("%" + search + "%"))));
predicates.add(root.get("id").in(idsList));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return closing(paginateQuery(em.createQuery(queryBuilder), first, max).getResultStream())
.map(g -> session.groups().getGroupById(realm, g))
.filter(Objects::nonNull);
}
@Override
@ -590,18 +631,30 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
return getGroupsStream(realm, ids);
}
List<String> idsList = ids.collect(Collectors.toList());
List<String> idsList = ids.toList();
if (idsList.isEmpty()) {
return Stream.empty();
}
TypedQuery<String> query = em.createNamedQuery("getGroupIdsFromIdList", String.class)
.setParameter("realm", realm.getId())
.setParameter("ids", idsList);
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
queryBuilder.select(root.get("id"));
return closing(paginateQuery(query, first, max).getResultStream())
.map(g -> session.groups().getGroupById(realm, g)).filter(Objects::nonNull);
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.add(root.get("id").in(idsList));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return closing(em.createQuery(queryBuilder).getResultStream())
.map(g -> session.groups().getGroupById(realm, g))
.filter(Objects::nonNull);
}
@Override
@ -611,31 +664,52 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public Long getGroupsCount(RealmModel realm, Stream<String> ids, String search) {
TypedQuery<Long> query;
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<Long> queryBuilder = builder.createQuery(Long.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
queryBuilder.select(builder.count(root.get("id")));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
if (search != null && !search.isEmpty()) {
query = em.createNamedQuery("getGroupCountByNameContainingFromIdList", Long.class)
.setParameter("search", search);
} else {
query = em.createNamedQuery("getGroupIdsFromIdList", Long.class);
predicates.add(builder.like(builder.lower(root.get("name")), builder.lower(builder.literal("%" + search + "%"))));
}
return query.setParameter("realm", realm.getId())
.setParameter("ids", ids.collect(Collectors.toList()))
.getSingleResult();
predicates.add(root.get("id").in(ids.toList()));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return em.createQuery(queryBuilder).getSingleResult();
}
@Override
public Long getGroupsCount(RealmModel realm, Boolean onlyTopGroups) {
if(Objects.equals(onlyTopGroups, Boolean.TRUE)) {
return em.createNamedQuery("getGroupCountByParent", Long.class)
.setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID)
.getSingleResult();
} else {
return em.createNamedQuery("getGroupCount", Long.class)
.setParameter("realm", realm.getId())
.getSingleResult();
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<Long> queryBuilder = builder.createQuery(Long.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
queryBuilder.select(builder.count(root.get("id")));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
if (Objects.equals(onlyTopGroups, Boolean.TRUE)) {
predicates.add(builder.equal(root.get("parentId"), GroupEntity.TOP_PARENT_ID));
}
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
return em.createQuery(queryBuilder).getSingleResult();
}
@Override
@ -665,18 +739,30 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public Stream<GroupModel> getTopLevelGroupsStream(RealmModel realm, String search, Boolean exact, Integer firstResult, Integer maxResults) {
TypedQuery<String> groupsQuery;
if(Boolean.TRUE.equals(exact)) {
groupsQuery = em.createNamedQuery("getGroupIdsByParentAndName", String.class);
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
queryBuilder.select(root.get("id"));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
predicates.add(builder.equal(root.get("parentId"), GroupEntity.TOP_PARENT_ID));
if (Boolean.TRUE.equals(exact)) {
predicates.add(builder.like(root.get("name"), search));
} else {
groupsQuery = em.createNamedQuery("getGroupIdsByParentAndNameContaining", String.class);
predicates.add(builder.like(builder.lower(root.get("name")), builder.lower(builder.literal("%" + search + "%"))));
}
groupsQuery.setParameter("realm", realm.getId())
.setParameter("parent", GroupEntity.TOP_PARENT_ID)
.setParameter("search", search);
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
return closing(paginateQuery(groupsQuery, firstResult, maxResults).getResultStream()
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return closing(paginateQuery(em.createQuery(queryBuilder), firstResult, maxResults).getResultStream()
.map(realm::getGroupById)
// In concurrent tests, the group might be deleted in another thread, therefore, skip those null values.
.filter(Objects::nonNull)
@ -1176,17 +1262,33 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
}
@Override
public Stream<GroupModel> searchForGroupByNameStream(RealmModel realm, String search, Boolean exact, Integer first, Integer max) {
TypedQuery<String> query;
if (Boolean.TRUE.equals(exact)) {
query = em.createNamedQuery("getGroupIdsByName", String.class);
} else {
query = em.createNamedQuery("getGroupIdsByNameContaining", String.class);
}
query.setParameter("realm", realm.getId())
.setParameter("search", search);
Stream<String> groups = paginateQuery(query, first, max).getResultStream();
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<String> queryBuilder = builder.createQuery(String.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
return closing(groups.map(id -> session.groups().getGroupById(realm, id)).filter(Objects::nonNull).sorted(GroupModel.COMPARE_BY_NAME).distinct());
queryBuilder.select(root.get("id"));
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
predicates.add(builder.equal(root.get("type"), Type.REALM.intValue()));
if (Boolean.TRUE.equals(exact)) {
predicates.add(builder.equal(root.get("name"), search));
} else {
predicates.add(builder.like(builder.lower(root.get("name")), builder.lower(builder.literal("%" + search + "%"))));
}
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(new Predicate[0]));
queryBuilder.orderBy(builder.asc(root.get("name")));
return closing(paginateQuery(em.createQuery(queryBuilder), first, max).getResultStream()
.map(id -> session.groups().getGroupById(realm, id))
.filter(Objects::nonNull)
.sorted(GroupModel.COMPARE_BY_NAME)
.distinct());
}
@Override
@ -1219,6 +1321,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
predicates.add(builder.and(attrNamePredicate, attrValuePredicate));
}
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, builder, queryBuilder, root));
Predicate finalPredicate = builder.and(predicates.toArray(new Predicate[0]));
queryBuilder.where(finalPredicate).orderBy(builder.asc(root.get("name")));

View File

@ -17,6 +17,7 @@
package org.keycloak.models.jpa;
import jakarta.persistence.criteria.Path;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.authorization.jpa.entities.ResourceEntity;
import org.keycloak.authorization.policy.provider.PartialEvaluationStorageProvider;
@ -74,7 +75,6 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
@ -496,9 +496,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group) {
TypedQuery<UserEntity> query = em.createNamedQuery("groupMembership", UserEntity.class);
query.setParameter("groupId", group.getId());
return closing(query.getResultStream().map(entity -> new UserAdapter(session, realm, em, entity)));
return getGroupMembersStream(realm, group, -1, -1);
}
@Override
@ -698,7 +696,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
session.setAttribute(UserModel.GROUPS, groupIds);
restrictions.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, this, realm, cb, countQuery));
restrictions.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, this, realm, cb, countQuery, root));
countQuery.where(restrictions.toArray(Predicate[]::new));
TypedQuery<Long> query = em.createQuery(countQuery);
@ -709,27 +707,70 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
TypedQuery<UserEntity> query = em.createNamedQuery("groupMembership", UserEntity.class);
query.setParameter("groupId", group.getId());
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<UserEntity> queryBuilder = builder.createQuery(UserEntity.class);
Root<UserGroupMembershipEntity> root = queryBuilder.from(UserGroupMembershipEntity.class);
Path<UserEntity> userPath = root.get("user");
return closing(paginateQuery(query, firstResult, maxResults).getResultStream().map(user -> new UserAdapter(session, realm, em, user)));
queryBuilder.select(userPath);
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("groupId"), group.getId()));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, this, realm, builder, queryBuilder, userPath));
queryBuilder.where(predicates.toArray(Predicate[]::new)).orderBy(builder.asc(userPath.get(UserModel.USERNAME)));
return closing(paginateQuery(em.createQuery(queryBuilder), firstResult, maxResults).getResultStream().map(user -> new UserAdapter(session, realm, em, user)));
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, String search, Boolean exact, Integer first, Integer max) {
TypedQuery<UserEntity> query;
if (StringUtil.isBlank(search)) {
query = em.createNamedQuery("groupMembership", UserEntity.class);
} else if (Boolean.TRUE.equals(exact)) {
query = em.createNamedQuery("groupMembershipByUser", UserEntity.class);
query.setParameter("search", search);
} else {
query = em.createNamedQuery("groupMembershipByUserContained", UserEntity.class);
query.setParameter("search", search.toLowerCase());
return getGroupMembersStream(realm, group, first, max);
}
query.setParameter("groupId", group.getId());
return closing(paginateQuery(query, first, max).getResultStream().map(user -> new UserAdapter(session, realm, em, user)));
// select g.user from UserGroupMembershipEntity g where g.groupId = :groupId and " +
// "(g.user.username = :search or g.user.email = :search or g.user.firstName = :search or g.user.lastName = :search) order by g.user.username
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<UserEntity> queryBuilder = builder.createQuery(UserEntity.class);
Root<UserGroupMembershipEntity> root = queryBuilder.from(UserGroupMembershipEntity.class);
Path<UserEntity> userPath = root.get("user");
queryBuilder.select(userPath);
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("groupId"), group.getId()));
if (Boolean.TRUE.equals(exact)) {
predicates.add(builder.and(
builder.or(
builder.equal(userPath.get("username"), search)),
builder.equal(userPath.get("email"), search),
builder.equal(userPath.get("firstName"), search),
builder.equal(userPath.get("lastName"), search)
)
);
} else {
predicates.add(builder.and(
builder.or(
builder.like(builder.lower(userPath.get("username")), builder.lower(builder.literal("%" + search + "%"))),
builder.like(builder.lower(userPath.get("email")), builder.lower(builder.literal("%" + search + "%"))),
builder.like(builder.lower(userPath.get("firstName")), builder.lower(builder.literal("%" + search + "%"))),
builder.like(builder.lower(userPath.get("lastName")), builder.lower(builder.literal("%" + search + "%")))
)
));
}
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, this, realm, builder, queryBuilder, userPath));
queryBuilder.where(predicates.toArray(Predicate[]::new)).orderBy(builder.asc(userPath.get(UserModel.USERNAME)));
return closing(paginateQuery(em.createQuery(queryBuilder), first, max).getResultStream().map(user -> new UserAdapter(session, realm, em, user)));
}
@Override
@ -764,7 +805,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
predicates.add(builder.equal(root.get("realmId"), realm.getId()));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, this, realm, builder, queryBuilder));
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, this, realm, builder, queryBuilder, root));
queryBuilder.where(predicates.toArray(Predicate[]::new)).orderBy(builder.asc(root.get(UserModel.USERNAME)));
@ -1058,18 +1099,40 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
@Override
public List<Predicate> getFilters(EvaluationContext evaluationContext) {
@SuppressWarnings("unchecked")
Set<String> userGroups = (Set<String>) session.getAttribute(UserModel.GROUPS);
Predicate groupFilterPredicate = null;
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(session.getContext().getRealm())) {
// support for FGAP v1
Set<String> userGroups = (Set<String>) session.getAttribute(UserModel.GROUPS);
if (userGroups != null) {
groupFilterPredicate = groupsWithPermissionsSubquery(session, evaluationContext, userGroups);
if (userGroups != null) {
return List.of(getFilterByGroupMembership(session, evaluationContext, userGroups));
}
return List.of();
}
return groupFilterPredicate == null ? List.of() : List.of(groupFilterPredicate);
Predicate predicate = getFilterByGroupMembership(evaluationContext, false);
if (predicate != null) {
return List.of(predicate);
}
return List.of();
}
private Predicate groupsWithPermissionsSubquery(KeycloakSession session, EvaluationContext evaluationContext, Set<String> groupIds) {
@Override
public List<Predicate> getNegateFilters(EvaluationContext evaluationContext) {
Predicate predicate = getFilterByGroupMembership(evaluationContext, true);
if (predicate != null) {
return List.of(predicate);
}
return List.of();
}
@Deprecated
private Predicate getFilterByGroupMembership(KeycloakSession session, EvaluationContext evaluationContext, Set<String> groupIds) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<?> query = evaluationContext.criteriaQuery();
Subquery subquery = query.subquery(String.class);
@ -1081,7 +1144,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
subPredicates.add(from.get("groupId").in(groupIds));
Root<?> root = evaluationContext.getRootEntity();
Path<?> root = evaluationContext.path();
subPredicates.add(cb.equal(from.get("user").get("id"), root.get("id")));
@ -1111,4 +1174,36 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, Parti
return cb.exists(subquery);
}
private Predicate getFilterByGroupMembership(EvaluationContext evaluationContext, boolean negate) {
if (negate && evaluationContext.deniedGroupIds().isEmpty()) {
return null;
}
if (!negate && evaluationContext.allowedGroupIds().isEmpty()) {
return null;
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<?> query = evaluationContext.criteriaQuery();
Subquery subquery = query.subquery(String.class);
Root<?> from = subquery.from(UserGroupMembershipEntity.class);
subquery.select(cb.literal(1));
List<Predicate> subPredicates = new ArrayList<>();
subPredicates.add(from.get("groupId").in(negate ? evaluationContext.deniedGroupIds() : evaluationContext.allowedGroupIds()));
Path<?> root = evaluationContext.path();
subPredicates.add(cb.equal(from.get("user").get("id"), root.get("id")));
subquery.where(subPredicates.toArray(Predicate[]::new));
if (negate) {
return cb.not(cb.exists(subquery));
}
return cb.exists(subquery);
}
}

View File

@ -29,16 +29,6 @@ import java.util.LinkedList;
*/
@NamedQueries({
@NamedQuery(name="getGroupIdsByParent", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and u.parentId = :parent order by u.name ASC"),
@NamedQuery(name="getGroupIdsByParentAndName", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and u.parentId = :parent and u.name = :search order by u.name ASC"),
@NamedQuery(name="getGroupIdsByParentAndNameContaining", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and u.parentId = :parent and lower(u.name) like lower(concat('%',:search,'%')) order by u.name ASC"),
@NamedQuery(name="getGroupIdsByRealm", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 order by u.name ASC"),
@NamedQuery(name="getGroupIdsByNameContaining", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and lower(u.name) like lower(concat('%',:search,'%')) order by u.name ASC"),
@NamedQuery(name="getGroupIdsByNameContainingFromIdList", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and lower(u.name) like lower(concat('%',:search,'%')) and u.id in :ids order by u.name ASC"),
@NamedQuery(name="getGroupIdsByName", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and u.name = :search order by u.name ASC"),
@NamedQuery(name="getGroupIdsFromIdList", query="select u.id from GroupEntity u where u.realm = :realm and u.type = 0 and u.id in :ids order by u.name ASC"),
@NamedQuery(name="getGroupCountByNameContainingFromIdList", query="select count(u) from GroupEntity u where u.realm = :realm and u.type = 0 and lower(u.name) like lower(concat('%',:search,'%')) and u.id in :ids"),
@NamedQuery(name="getGroupCount", query="select count(u) from GroupEntity u where u.realm = :realm and u.type = 0"),
@NamedQuery(name="getGroupCountByParent", query="select count(u) from GroupEntity u where u.realm = :realm and u.type = 0 and u.parentId = :parent"),
@NamedQuery(name="deleteGroupsByRealm", query="delete from GroupEntity g where g.realm = :realm")
})
@Entity

View File

@ -37,12 +37,6 @@ import org.keycloak.representations.idm.MembershipType;
@NamedQueries({
@NamedQuery(name="userMemberOf", query="select m from UserGroupMembershipEntity m where m.user = :user and m.groupId = :groupId"),
@NamedQuery(name="userGroupMembership", query="select m from UserGroupMembershipEntity m where m.user = :user"),
@NamedQuery(name="groupMembership", query="select g.user from UserGroupMembershipEntity g where g.groupId = :groupId order by g.user.username"),
@NamedQuery(name="groupMembershipByUser", query="select g.user from UserGroupMembershipEntity g where g.groupId = :groupId and " +
"(g.user.username = :search or g.user.email = :search or g.user.firstName = :search or g.user.lastName = :search) order by g.user.username"),
@NamedQuery(name="groupMembershipByUserContained", query="select g.user from UserGroupMembershipEntity g where g.groupId = :groupId and " +
"(g.user.username like concat('%',:search,'%') or g.user.email like concat('%',:search,'%') or lower(g.user.firstName) like concat('%',:search,'%') or " +
"lower(g.user.lastName) like concat('%',:search,'%')) order by g.user.username"),
@NamedQuery(name="deleteUserGroupMembershipByRealm", query="delete from UserGroupMembershipEntity mapping where mapping.user IN (select u from UserEntity u where u.realmId=:realmId)"),
@NamedQuery(name="deleteUserGroupMembershipsByRealmAndLink", query="delete from UserGroupMembershipEntity mapping where mapping.user IN (select u from UserEntity u where u.realmId=:realmId and u.federationLink=:link)"),
@NamedQuery(name="deleteUserGroupMembershipsByGroup", query="delete from UserGroupMembershipEntity m where m.groupId = :groupId"),

View File

@ -145,9 +145,11 @@ public class BruteForceUsersResource {
private Stream<BruteUser> searchForUser(Map<String, String> attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) {
attributes.put(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts.toString());
Set<String> groupIds = auth.groups().getGroupIdsWithViewPermission();
if (!groupIds.isEmpty()) {
session.setAttribute(UserModel.GROUPS, groupIds);
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
Set<String> groupIds = auth.groups().getGroupIdsWithViewPermission();
if (!groupIds.isEmpty()) {
session.setAttribute(UserModel.GROUPS, groupIds);
}
}
return toRepresentation(realm, usersEvaluator, briefRepresentation, session.users().searchForUserStream(realm, attributes, firstResult, maxResults));
@ -158,6 +160,7 @@ public class BruteForceUsersResource {
boolean briefRepresentationB = briefRepresentation != null && briefRepresentation;
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
userModels = userModels.filter(usersEvaluator::canView);
usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
}

View File

@ -24,6 +24,7 @@ import java.util.stream.Collectors;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.Resource;
@ -436,8 +437,12 @@ public class AdminPermissionsSchema extends AuthorizationSchema {
}
}
public List<Predicate> applyAuthorizationFilters(KeycloakSession session, ResourceType resourceType, PartialEvaluationStorageProvider evaluator, RealmModel realm, CriteriaBuilder builder, CriteriaQuery<?> queryBuilder) {
return partialEvaluator.applyAuthorizationFilters(session, resourceType, evaluator, realm, builder, queryBuilder);
public List<Predicate> applyAuthorizationFilters(KeycloakSession session, ResourceType resourceType, RealmModel realm, CriteriaBuilder builder, CriteriaQuery<?> queryBuilder, Path<?> path) {
return applyAuthorizationFilters(session, resourceType, null, realm, builder, queryBuilder, path);
}
public List<Predicate> applyAuthorizationFilters(KeycloakSession session, ResourceType resourceType, PartialEvaluationStorageProvider evaluator, RealmModel realm, CriteriaBuilder builder, CriteriaQuery<?> queryBuilder, Path<?> path) {
return partialEvaluator.applyAuthorizationFilters(session, resourceType, evaluator, realm, builder, queryBuilder, path);
}
public PolicyEvaluator getPolicyEvaluator(KeycloakSession session, ResourceServer resourceServer) {

View File

@ -23,13 +23,12 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.keycloak.Config;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.Resource;
@ -50,10 +49,10 @@ import org.keycloak.representations.idm.authorization.ResourceType;
public class PartialEvaluator {
public List<Predicate> applyAuthorizationFilters(KeycloakSession session, ResourceType resourceType, PartialEvaluationStorageProvider evaluator, RealmModel realm, CriteriaBuilder builder, CriteriaQuery<?> queryBuilder) {
public List<Predicate> applyAuthorizationFilters(KeycloakSession session, ResourceType resourceType, PartialEvaluationStorageProvider storage, RealmModel realm, CriteriaBuilder builder, CriteriaQuery<?> queryBuilder, Path<?> path) {
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
// feature not enabled, if a storage evaluator is provided try to resolve any filter from there
return evaluator == null ? List.of() : evaluator.getFilters(new EvaluationContext(resourceType, queryBuilder));
return storage == null ? List.of() : storage.getFilters(new EvaluationContext(resourceType, queryBuilder, path, Set.of(), Set.of()));
}
KeycloakContext context = session.getContext();
@ -66,38 +65,48 @@ public class PartialEvaluator {
// collect the result from the partial evaluation so that the filters can be applied
PartialResourceEvaluationResult result = evaluate(session, adminUser, resourceType);
EvaluationContext evaluationContext = new EvaluationContext(resourceType, queryBuilder);
Root<?> root = evaluationContext.getRootEntity();
EvaluationContext evaluationContext = new EvaluationContext(resourceType, queryBuilder, path, new HashSet<>(), new HashSet<>());
if (AdminPermissionsSchema.USERS.equals(resourceType)) {
PartialResourceEvaluationResult evaluateGroups = evaluate(session, adminUser, AdminPermissionsSchema.GROUPS);
evaluationContext.allowedGroupIds().addAll(evaluateGroups.allowedIds());
evaluationContext.deniedGroupIds().addAll(evaluateGroups.deniedIds());
}
List<Predicate> predicates = new ArrayList<>();
Set<String> deniedIds = result.deniedIds();
if (!deniedIds.isEmpty()) {
// add filters to remove denied resources from the result set
predicates.add(builder.not(root.get("id").in(deniedIds)));
predicates.add(builder.not(path.get("id").in(deniedIds)));
}
List<Predicate> evaluate = evaluator == null ? List.of() : evaluator.getFilters(evaluationContext);
List<Predicate> storageFilters = storage == null ? List.of() : storage.getFilters(evaluationContext);
List<Predicate> storageNegateFilters = storage == null ? List.of() : storage.getNegateFilters(evaluationContext);
if (evaluate.isEmpty() && (result.isResourceTypedDenied() || (!deniedIds.isEmpty() && result.rawAllowedIds().isEmpty()))) {
predicates.addAll(storageNegateFilters);
if (storageFilters.isEmpty() && (result.isResourceTypedDenied() || (!deniedIds.isEmpty() && result.rawAllowedIds().isEmpty()))) {
// do not return any result because there is no filter from the evaluator, and access is denied for the resource type
return List.of(builder.equal(root.get("id"), "none"));
return List.of(builder.equal(path.get("id"), "none"));
}
Set<String> allowedIds = result.allowedIds();
if (allowedIds.isEmpty()) {
// no resources granted, filter them based on any filter previously set
predicates.addAll(evaluate);
predicates.addAll(storageFilters);
return predicates;
}
if (evaluate.isEmpty()) {
if (storageFilters.isEmpty()) {
// no filter from the evaluator, filter based on the resources that were granted
predicates.add(builder.and(root.get("id").in(allowedIds)));
predicates.add(builder.and(path.get("id").in(allowedIds)));
} else {
// there are filters from the evaluator, the resources granted will be a returned using a or condition
List<Predicate> orPredicates = new ArrayList<>(evaluate);
orPredicates.add(root.get("id").in(allowedIds));
List<Predicate> orPredicates = new ArrayList<>(storageFilters);
orPredicates.add(path.get("id").in(allowedIds));
predicates.add(builder.or(orPredicates.toArray(new Predicate[0])));
}
@ -124,30 +133,27 @@ public class PartialEvaluator {
List<PartialEvaluationPolicyProvider> policyProviders = getPartialEvaluationPolicyProviders(session);
for (PartialEvaluationPolicyProvider policyProvider : policyProviders) {
policyProvider.getPermissions(session, resourceType, adminUser).forEach(new Consumer<Policy>() {
@Override
public void accept(Policy permission) {
Set<String> ids = permission.getResources().stream().map(Resource::getName).collect(Collectors.toSet());
Set<Policy> policies = permission.getAssociatedPolicies();
policyProvider.getPermissions(session, resourceType, adminUser).forEach(permission -> {
Set<String> ids = permission.getResources().stream().map(Resource::getName).collect(Collectors.toSet());
Set<Policy> policies = permission.getAssociatedPolicies();
for (Policy policy : policies) {
PartialEvaluationPolicyProvider provider = policyProviders.stream().filter((p) -> p.supports(policy)).findAny().orElse(null);
for (Policy policy : policies) {
PartialEvaluationPolicyProvider provider = policyProviders.stream().filter((p) -> p.supports(policy)).findAny().orElse(null);
if (provider == null) {
continue;
}
if (provider == null) {
continue;
}
boolean granted = provider.evaluate(session, policy, adminUser);
boolean granted = provider.evaluate(session, policy, adminUser);
if (Logic.NEGATIVE.equals(policy.getLogic())) {
granted = !granted;
}
if (Logic.NEGATIVE.equals(policy.getLogic())) {
granted = !granted;
}
if (granted) {
allowedIds.addAll(ids);
} else {
deniedIds.addAll(ids);
}
if (granted) {
allowedIds.addAll(ids);
} else {
deniedIds.addAll(ids);
}
}
});

View File

@ -22,9 +22,8 @@ import java.util.List;
import java.util.Set;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.representations.idm.authorization.ResourceType;
/**
@ -35,7 +34,7 @@ import org.keycloak.representations.idm.authorization.ResourceType;
public interface PartialEvaluationStorageProvider {
/**
* A callback method that will be called when building queries for realm resources. It returns a list of
* A callback method that will be called when building queries for realm resources to grant access to resources. It returns a list of
* {@link Predicate} instances representing the filters that should be applied to queries
* when querying realm resources.
*
@ -44,22 +43,24 @@ public interface PartialEvaluationStorageProvider {
*/
List<Predicate> getFilters(EvaluationContext evaluationContext);
/**
* A callback method that will be called when building queries for realm resources to deny access to resources. It returns a list of
* {@link Predicate} instances representing the filters that should be applied to queries
* when querying realm resources.
*
* @param evaluationContext the evaluation context.
* @return the list of predicates
*/
List<Predicate> getNegateFilters(EvaluationContext evaluationContext);
/**
* An {@link EvaluationContext} instance provides access to contextual information when building a query for realm
* resources of a given {@link ResourceType}.
*
* @param resourceType the type of the resource to query
* @param criteriaQuery the query to rely on when building predicates
* @param path the path for the root entity
*/
record EvaluationContext(ResourceType resourceType, CriteriaQuery<?> criteriaQuery) {
public Root<?> getRootEntity() {
Set<Root<?>> roots = criteriaQuery.getRoots();
if (roots.size() != 1) {
throw new ModelIllegalStateException("Could not find any root entity from query");
}
return roots.iterator().next();
}
record EvaluationContext(ResourceType resourceType, CriteriaQuery<?> criteriaQuery, Path<?> path, Set<String> allowedGroupIds, Set<String> deniedGroupIds) {
}
}

View File

@ -291,16 +291,23 @@ public abstract class DefaultKeycloakContext implements KeycloakContext {
@Override
public UserModel getUser() {
UserModel user = null;
if (bearerToken instanceof JsonWebToken jwt) {
UserModel user = session.users().getUserById(realm, jwt.getSubject());
String issuer = jwt.getIssuer();
String realmName = issuer.substring(issuer.lastIndexOf("/") + 1);
RealmModel realm = session.realms().getRealmByName(realmName);
user = session.users().getUserById(realm, jwt.getSubject());
}
if (user == null) {
throw new IllegalStateException("Could not resolve subject with id: " + jwt.getSubject());
}
if (user == null) {
user = userSession == null ? null : userSession.getUser();
}
if (user != null) {
return user;
}
return userSession == null ? null : userSession.getUser();
throw new IllegalStateException("Could not resolve subject");
}
}

View File

@ -21,6 +21,7 @@ import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.events.admin.OperationType;
@ -174,9 +175,14 @@ public class GroupResource {
@Parameter(description = "The maximum number of results that are to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max,
@Parameter(description = "Boolean which defines whether brief groups representations are returned or not (default: false)") @QueryParam("briefRepresentation") @DefaultValue("false") Boolean briefRepresentation) {
this.auth.groups().requireView(group);
return paginatedStream(
group.getSubGroupsStream(search, exact, -1, -1)
.filter(auth.groups()::canView), first, max)
Stream<GroupModel> stream = group.getSubGroupsStream(search, exact, -1, -1);
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
stream = stream.filter(auth.groups()::canView);
}
return paginatedStream(stream, first, max)
.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, !briefRepresentation)));
}

View File

@ -40,6 +40,7 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
@ -110,8 +111,11 @@ public class GroupsResource {
return GroupUtils.populateGroupHierarchyFromSubGroups(session, realm, stream, !briefRepresentation, groupsEvaluator);
}
return stream.filter(groupsEvaluator::canView)
.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(groupsEvaluator, g, !briefRepresentation)));
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
stream = stream.filter(groupsEvaluator::canView);
}
return stream.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(groupsEvaluator, g, !briefRepresentation)));
}
/**

View File

@ -417,7 +417,11 @@ public class UsersResource {
} else if (userPermissionEvaluator.canView()) {
return session.users().getUsersCount(realm, search.trim());
} else {
return session.users().getUsersCount(realm, search.trim(), auth.groups().getGroupIdsWithViewPermission());
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
return session.users().getUsersCount(realm, search.trim());
} else {
return session.users().getUsersCount(realm, search.trim(), auth.groups().getGroupIdsWithViewPermission());
}
}
} else if (last != null || first != null || email != null || username != null || emailVerified != null || enabled != null || !searchAttributes.isEmpty()) {
Map<String, String> parameters = new HashMap<>();
@ -444,11 +448,18 @@ public class UsersResource {
if (userPermissionEvaluator.canView()) {
return session.users().getUsersCount(realm, parameters);
} else {
return session.users().getUsersCount(realm, parameters, auth.groups().getGroupIdsWithViewPermission());
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
return session.users().getUsersCount(realm, parameters);
} else {
return session.users().getUsersCount(realm, parameters, auth.groups().getGroupIdsWithViewPermission());
}
}
} else if (userPermissionEvaluator.canView()) {
return session.users().getUsersCount(realm);
} else {
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
return session.users().getUsersCount(realm);
}
return session.users().getUsersCount(realm, auth.groups().getGroupIdsWithViewPermission());
}
}
@ -467,9 +478,11 @@ public class UsersResource {
private Stream<UserRepresentation> searchForUser(Map<String, String> attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) {
attributes.put(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts.toString());
Set<String> groupIds = auth.groups().getGroupIdsWithViewPermission();
if (!groupIds.isEmpty()) {
session.setAttribute(UserModel.GROUPS, groupIds);
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
Set<String> groupIds = auth.groups().getGroupIdsWithViewPermission();
if (!groupIds.isEmpty()) {
session.setAttribute(UserModel.GROUPS, groupIds);
}
}
return toRepresentation(realm, usersEvaluator, briefRepresentation, session.users().searchForUserStream(realm, attributes, firstResult, maxResults));
@ -479,6 +492,7 @@ public class UsersResource {
boolean briefRepresentationB = briefRepresentation != null && briefRepresentation;
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
userModels = userModels.filter(usersEvaluator::canView);
usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
}

View File

@ -4,6 +4,8 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -27,8 +29,11 @@ public class GroupUtils {
public static Stream<GroupRepresentation> populateGroupHierarchyFromSubGroups(KeycloakSession session, RealmModel realm, Stream<GroupModel> groups, boolean full, GroupPermissionEvaluator groupEvaluator) {
Map<String, GroupRepresentation> groupIdToGroups = new HashMap<>();
groups.forEach(group -> {
//TODO GROUPS do permissions work in such a way that if you can view the children you can definitely view the parents?
if(!groupEvaluator.canView() && !groupEvaluator.canView(group)) return;
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
//TODO GROUPS do permissions work in such a way that if you can view the children you can definitely view the parents?
if (!groupEvaluator.canView() && !groupEvaluator.canView(group)) return;
}
GroupRepresentation currGroup = toRepresentation(groupEvaluator, group, full);
populateSubGroupCount(group, currGroup);
@ -37,11 +42,12 @@ public class GroupUtils {
while(currGroup.getParentId() != null) {
GroupModel parentModel = session.groups().getGroupById(realm, currGroup.getParentId());
//TODO GROUPS not sure if this is even necessary but if somehow you can't view the parent we need to remove the child and move on
if(!groupEvaluator.canView() && !groupEvaluator.canView(parentModel)) {
groupIdToGroups.remove(currGroup.getId());
break;
}
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
//TODO GROUPS not sure if this is even necessary but if somehow you can't view the parent we need to remove the child and move on
if(!groupEvaluator.canView() && !groupEvaluator.canView(parentModel)) {
groupIdToGroups.remove(currGroup.getId());
break;
} }
GroupRepresentation parent = groupIdToGroups.computeIfAbsent(currGroup.getParentId(),
id -> toRepresentation(groupEvaluator, parentModel, full));

View File

@ -0,0 +1,143 @@
/*
* Copyright 2025 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.tests.admin.authz.fgap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.keycloak.authorization.AdminPermissionsSchema.GROUPS_RESOURCE_TYPE;
import static org.keycloak.authorization.AdminPermissionsSchema.VIEW;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.GroupResource;
import org.keycloak.admin.client.resource.GroupsResource;
import org.keycloak.admin.client.resource.ScopePermissionsResource;
import org.keycloak.authorization.AdminPermissionsSchema;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.authorization.Logic;
import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation;
import org.keycloak.representations.idm.authorization.UserPolicyRepresentation;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.util.ApiUtil;
@KeycloakIntegrationTest(config = KeycloakAdminPermissionsServerConfig.class)
public class GroupResourceTypeFilteringTest extends AbstractPermissionTest {
@InjectAdminClient(mode = InjectAdminClient.Mode.MANAGED_REALM, client = "myclient", user = "myadmin")
Keycloak realmAdminClient;
@BeforeEach
public void onBeforeEach() {
for (int i = 0; i < 50; i++) {
GroupRepresentation group = new GroupRepresentation();
group.setName("group-" + i);
try (Response response = realm.admin().groups().add(group)) {
group.setId(ApiUtil.getCreatedId(response));
}
GroupResource groupResource = realm.admin().groups().group(group.getId());
for (int j = 0; j < 5; j++) {
GroupRepresentation subGroup = new GroupRepresentation();
subGroup.setName("subgroup-" + i + "." + j);
groupResource.subGroup(subGroup).close();
}
}
}
@AfterEach
public void onAfterEach() {
ScopePermissionsResource permissions = getScopePermissionsResource(client);
for (ScopePermissionRepresentation permission : permissions.findAll(null, null, null, -1, -1)) {
permissions.findById(permission.getId()).remove();
}
GroupsResource groups = realm.admin().groups();
groups.groups().forEach(group -> groups.group(group.getId()).remove());
}
@Test
public void testViewAllGroupsUsingUserPolicy() {
List<GroupRepresentation> search = realmAdminClient.realm(realm.getName()).groups().groups();
assertTrue(search.isEmpty());
UserPolicyRepresentation policy = createUserPolicy(realm, client,"Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createAllPermission(client, GROUPS_RESOURCE_TYPE, policy, Set.of(VIEW));
search = realmAdminClient.realm(realm.getName()).groups().groups();
assertFalse(search.isEmpty());
assertEquals(50, search.size());
}
@Test
public void testDeniedResourcesPrecedenceOverGrantedResources() {
UserPolicyRepresentation policy = createUserPolicy(realm, client,"Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createAllPermission(client, GROUPS_RESOURCE_TYPE, policy, Set.of(VIEW));
List<GroupRepresentation> search = realmAdminClient.realm(realm.getName()).groups().groups();
assertFalse(search.isEmpty());
assertEquals(50, search.size());
UserPolicyRepresentation notMyAdminPolicy = createUserPolicy(Logic.NEGATIVE, realm, client,"Not My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
Set<String> notAllowedGroups = search.stream()
.filter((g) -> Set.of("group-0", "group-15", "group-30", "group-45").contains(g.getName()))
.map(GroupRepresentation::getId)
.collect(Collectors.toSet());
createPermission(client, notAllowedGroups, GROUPS_RESOURCE_TYPE, Set.of(VIEW), notMyAdminPolicy);
search = realmAdminClient.realm(realm.getName()).groups().groups();
assertFalse(search.isEmpty());
assertTrue(search.stream().map(GroupRepresentation::getName).noneMatch(notAllowedGroups::contains));
}
@Test
public void testFilterSubGroups() {
UserPolicyRepresentation policy = createUserPolicy(realm, client,"Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createAllPermission(client, GROUPS_RESOURCE_TYPE, policy, Set.of(VIEW));
List<GroupRepresentation> search = realmAdminClient.realm(realm.getName()).groups().groups("subgroup-0.0", -1, -1);
assertFalse(search.isEmpty());
assertEquals(1, search.size());
GroupRepresentation group = search.get(0);
assertEquals(1, group.getSubGroups().size());
GroupRepresentation subGroup = group.getSubGroups().get(0);
assertEquals("subgroup-0.0", subGroup.getName());
UserPolicyRepresentation notMyAdminPolicy = createUserPolicy(Logic.NEGATIVE, realm, client,"Not My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createPermission(client, subGroup.getId(), GROUPS_RESOURCE_TYPE, Set.of(VIEW), notMyAdminPolicy);
search = realmAdminClient.realm(realm.getName()).groups().groups("subgroup-0.0", -1, -1);
assertTrue(search.isEmpty());
List<GroupRepresentation> subGroups = realmAdminClient.realm(realm.getName()).groups().group(group.getId()).getSubGroups(-1, -1, false);
assertTrue(subGroups.stream().map(GroupRepresentation::getId).noneMatch(subGroup.getId()::equals));
}
}

View File

@ -23,12 +23,15 @@ import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.keycloak.authorization.AdminPermissionsSchema.GROUPS_RESOURCE_TYPE;
import static org.keycloak.authorization.AdminPermissionsSchema.USERS_RESOURCE_TYPE;
import static org.keycloak.authorization.AdminPermissionsSchema.VIEW;
import static org.keycloak.authorization.AdminPermissionsSchema.VIEW_MEMBERS;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.ws.rs.core.Response;
import org.hamcrest.Matchers;
@ -108,10 +111,13 @@ public class UserResourceTypeFilteringTest extends AbstractPermissionTest {
@Test
public void testViewUserUsingUserPolicy() {
List<UserRepresentation> search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertTrue(search.isEmpty());
UserPolicyRepresentation policy = createUserPolicy(realm, client,"Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createPermission(client, "user-9", usersType, Set.of(VIEW), policy);
List<UserRepresentation> search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertFalse(search.isEmpty());
assertEquals(1, search.size());
}
@ -227,6 +233,77 @@ public class UserResourceTypeFilteringTest extends AbstractPermissionTest {
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertFalse(search.isEmpty());
assertTrue(search.stream().map(UserRepresentation::getUsername).noneMatch("user-0"::equals));
assertTrue(realmAdminClient.realm(realm.getName()).groups().group(group.getId()).members().stream().map(UserRepresentation::getUsername).noneMatch("user-0"::equals));
}
@Test
public void testDenyGroupViewMembersPolicy() {
List<UserRepresentation> search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertTrue(search.isEmpty());
GroupRepresentation allowedMembers = new GroupRepresentation();
allowedMembers.setName(KeycloakModelUtils.generateId());
Set<String> memberUsernames = Set.of("user-0", "user-15", "user-30", "user-45");
try (Response response = realm.admin().groups().add(allowedMembers)) {
allowedMembers.setId(ApiUtil.getCreatedId(response));
addGroupMember(allowedMembers.getId(), memberUsernames);
}
GroupRepresentation deniedMembers = new GroupRepresentation();
deniedMembers.setName(KeycloakModelUtils.generateId());
Set<String> deniedMemberUsernames = Set.of("user-0", "user-45");
try (Response response = realm.admin().groups().add(deniedMembers)) {
deniedMembers.setId(ApiUtil.getCreatedId(response));
addGroupMember(deniedMembers.getId(), memberUsernames.stream().filter(deniedMemberUsernames::contains).collect(Collectors.toSet()));
}
// grant access to se members of a group
UserPolicyRepresentation permitPolicy = createUserPolicy(realm, client,"Only My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createPermission(client, allowedMembers.getId(), AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, Set.of(VIEW_MEMBERS), permitPolicy);
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertEquals(memberUsernames.size(), search.size());
assertTrue(search.stream().map(UserRepresentation::getUsername).allMatch(memberUsernames::contains));
// deny access to the members of another group where access to some users in this group were previously granted
UserPolicyRepresentation denyPolicy = createUserPolicy(Logic.NEGATIVE, realm, client,"Not My Admin User Policy", realm.admin().users().search("myadmin").get(0).getId());
createPermission(client, deniedMembers.getId(), GROUPS_RESOURCE_TYPE, Set.of(VIEW_MEMBERS), denyPolicy);
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertFalse(search.isEmpty());
assertEquals(memberUsernames.size() - deniedMemberUsernames.size(), search.size());
assertTrue(search.stream().map(UserRepresentation::getUsername).noneMatch(deniedMemberUsernames::contains));
// grant access to a specific user that is protected, the permission will have no effect because the user cannot be accessed due to the group permission
String userId = realm.admin().users().search("user-0").get(0).getId();
createPermission(client, userId, USERS_RESOURCE_TYPE, Set.of(VIEW), permitPolicy);
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
Set<String> expected = new HashSet<>(memberUsernames);
expected.removeAll(deniedMemberUsernames);
assertFalse(search.isEmpty());
assertEquals(expected.size(), search.size());
assertTrue(search.stream().map(UserRepresentation::getUsername).allMatch(expected::contains));
// the user is no longer a member of the group that holds members that cannot be accessed, they can be accessed now
realm.admin().users().get(userId).leaveGroup(deniedMembers.getId());
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
expected = new HashSet<>(memberUsernames);
expected.removeAll(deniedMemberUsernames);
expected.add("user-0");
assertFalse(search.isEmpty());
assertEquals(expected.size(), search.size());
assertTrue(search.stream().map(UserRepresentation::getUsername).allMatch(expected::contains));
}
private void addGroupMember(String groupId, Set<String> usernames) {
for (String username: usernames) {
String id = realm.admin().users().search(username).get(0).getId();
realm.admin().users().get(id).joinGroup(groupId);
}
}
@Test