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 @@ -89,6 +89,14 @@ With fine-grained admin permissions v2, the listing and the count are restricted
With the deprecated fine-grained admin permissions v1, a caller can be allowed to view some resources but not others, so `totalResults` counts the resources matching the query, before the per-resource view check is applied, and should be treated as an upper bound; a page can also be shorter than the requested `count`, or even empty, while later pages still contain resources the caller is allowed to see, so neither a short page nor an empty page means that no further resources exist, and clients should keep incrementing `startIndex` by `count` until `startIndex` exceeds `totalResults`.
Without any fine-grained admin permissions, the per-resource view check evaluates the same way for every resource of a given type, so access is all-or-nothing: a client holding only a query role and no realm-wide view role gets a `totalResults` of `0`, consistently across every page, rather than the unfiltered database count.

=== Group lookup by path filters role mappings by visibility
// https://github.com/keycloak/keycloak/issues/54356

The Admin REST API endpoint `GET /admin/realms/\{realm}/group-by-path/\{path}` now omits the role mappings that the caller is not allowed to view or to map to users, as the `groups/\{id}` endpoint already does.
Previously, any caller allowed to view the group received all of its role mappings in `realmRoles` and `clientRoles`.
For example, an administrator with only the `view-users` role no longer receives them, while an administrator with the `manage-users` role still does.
If an integration reads group role mappings through this endpoint, make sure its account is allowed to view the roles, for example through the `view-realm` and `view-clients` roles or the corresponding fine-grained permissions.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ List<GroupRepresentation> groups(@QueryParam("search") String search,
@QueryParam("max") Integer max,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation);

/**
* Get groups by name with optional hierarchy population.
* @param search an exact or partial group name.
* @param exact whether to match the group name exactly.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @param briefRepresentation whether to return only basic information rather than full representations,
* including role mappings and attributes.
* @param subGroupsCount whether to include subgroup counts.
* @param populateHierarchy whether to return matching groups within their parent hierarchy.
* If {@code false}, matching groups are returned as a flat list.
* @return A list containing the matching groups.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
List<GroupRepresentation> groups(@QueryParam("search") String search,
@QueryParam("exact") Boolean exact,
@QueryParam("first") Integer first,
@QueryParam("max") Integer max,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation,
@QueryParam("subGroupsCount") @DefaultValue("true") Boolean subGroupsCount,
@QueryParam("populateHierarchy") @DefaultValue("true") boolean populateHierarchy);

/**
* Counts all groups.
* @return A map containing key "count" with number of groups as value.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,6 @@ public GroupModel getGroupByName(RealmModel realm, GroupModel parent, String nam
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.equal(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")));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.common.ClientConnection;
import org.keycloak.component.ComponentModel;
import org.keycloak.events.admin.OperationType;
Expand Down Expand Up @@ -253,24 +254,25 @@ public SynchronizationResult syncMapperData(@PathParam("parentId") String parent

ServicesLogger.LOGGER.syncingDataForMapper(mapperModel.getName(), mapperModel.getProviderId(), direction);

SynchronizationResult syncResult;
if ("fedToKeycloak".equals(direction)) {
try {
syncResult = mapper.syncDataFromFederationProviderToKeycloak(realm);
} catch(Exception e) {
String errorMsg = getErrorCode(e);
throw ErrorResponse.error(errorMsg, Response.Status.BAD_REQUEST);
}
} else if ("keycloakToFed".equals(direction)) {
try {
syncResult = mapper.syncDataFromKeycloakToFederationProvider(realm);
} catch(Exception e) {
String errorMsg = getErrorCode(e);
throw ErrorResponse.error(errorMsg, Response.Status.BAD_REQUEST);
SynchronizationResult syncResult = AdminPermissionsSchema.runWithoutAuthorization(session, () -> {
if ("fedToKeycloak".equals(direction)) {
try {
return mapper.syncDataFromFederationProviderToKeycloak(realm);
} catch(Exception e) {
String errorMsg = getErrorCode(e);
throw ErrorResponse.error(errorMsg, Response.Status.BAD_REQUEST);
}
} else if ("keycloakToFed".equals(direction)) {
try {
return mapper.syncDataFromKeycloakToFederationProvider(realm);
} catch(Exception e) {
String errorMsg = getErrorCode(e);
throw ErrorResponse.error(errorMsg, Response.Status.BAD_REQUEST);
}
} else {
throw new BadRequestException("Unknown direction: " + direction);
}
} else {
throw new BadRequestException("Unknown direction: " + direction);
}
});

Map<String, Object> eventRep = new HashMap<>();
eventRep.put("action", direction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.AccessToken;
import org.keycloak.scim.protocol.response.ErrorResponse;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
Expand All @@ -23,9 +22,11 @@ public class ScimRealmResource {
private static final Logger logger = Logger.getLogger(ScimRealmResource.class);

private final KeycloakSession session;
private final AdminAuth auth;

public ScimRealmResource(KeycloakSession session) {
public ScimRealmResource(KeycloakSession session, AdminAuth auth) {
this.session = session;
this.auth = auth;
}

@Path("/v2/{resourceType}")
Expand All @@ -47,8 +48,6 @@ public Object resourceType(@PathParam("resourceType") String resourceType) {

private AdminEventBuilder createAdminEventBuilder() {
RealmModel realm = session.getContext().getRealm();
AccessToken token = (AccessToken) session.getContext().getBearerToken();
AdminAuth auth = new AdminAuth(realm, token, session.getContext().getUser(), session.getContext().getClient());
return new AdminEventBuilder(realm, auth, session, session.getContext().getConnection());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.urls.UrlType;

import org.jboss.logging.Logger;
Expand Down Expand Up @@ -85,7 +86,10 @@ public Object getResource() {
.build());
}

return new ScimRealmResource(session);
// Binds the admin identity to the session, enabling fine-grained admin permission query filtering.
AdminAuth auth = new AdminAuth(session, realm, accessToken, authResult.user(), client);

return new ScimRealmResource(session, auth);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,10 +405,11 @@ public void testAllowGroupsFGAP() {
realm.admin().update(realmRep);
grantAdminRole(AdminRoles.QUERY_GROUPS);

GroupRepresentation group = createGroup();
createGroup("hidden-group");
ListResponse<Group> groups = noAccessClient.groups().getAll("");
assertEquals(0, groups.getTotalResults());

GroupRepresentation group = createGroup();
ClientRepresentation client = getScimClient();
UserRepresentation serviceAccount = realm.admin().clients().get(client.getId()).getServiceAccountUser();
UserPolicyRepresentation policy = new UserPolicyRepresentation();
Expand All @@ -425,6 +426,7 @@ public void testAllowGroupsFGAP() {
permissionClient.authorization().permissions().scope().create(permission).close();
groups = noAccessClient.groups().getAll("");
assertEquals(1, groups.getTotalResults());
assertEquals(group.getId(), groups.getResources().get(0).getId());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public class AdminPermissionsSchema extends AuthorizationSchema {
public static final ResourceType ROLES = new ResourceType(ROLES_RESOURCE_TYPE, Set.of(MAP_ROLE, MAP_ROLE_CLIENT_SCOPE, MAP_ROLE_COMPOSITE));
public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, DELEGATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP, RESET_PASSWORD), Map.of(VIEW, Set.of(VIEW_MEMBERS), MANAGE, Set.of(MANAGE_MEMBERS), IMPERSONATE, Set.of(IMPERSONATE_MEMBERS), DELEGATE, Set.of(DELEGATE_MEMBERS), MANAGE_GROUP_MEMBERSHIP, Set.of(MANAGE_MEMBERSHIP_OF_MEMBERS)), GROUPS.getType());
public static final ResourceType ORGANIZATIONS = new ResourceType(ORGANIZATIONS_RESOURCE_TYPE, Set.of(MANAGE, VIEW));
private static final String AUTHORIZATION_USER = "kc.authz.fgap.user";
private static final String SKIP_EVALUATION = "kc.authz.fgap.skip";
public static final AdminPermissionsSchema SCHEMA = new AdminPermissionsSchema();

Expand Down Expand Up @@ -538,11 +539,11 @@ public List<Predicate> applyAuthorizationFilters(KeycloakSession session, Resour
}

public List<Predicate> applyAuthorizationFilters(KeycloakSession session, ResourceType resourceType, PartialEvaluationStorageProvider evaluator, RealmModel realm, CriteriaBuilder builder, CriteriaQuery<?> queryBuilder, Path<?> path) {
return partialEvaluator.getPredicates(session, resourceType, evaluator, realm, builder, queryBuilder, path);
return partialEvaluator.getPredicates(session, getAdminUser(session), resourceType, evaluator, realm, builder, queryBuilder, path);
}

public boolean isPartialEvaluationActive(KeycloakSession session, ResourceType resourceType) {
return partialEvaluator.isActive(session, resourceType);
return partialEvaluator.isActive(session, getAdminUser(session), resourceType);
}

public PolicyEvaluator getPolicyEvaluator(KeycloakSession session, ResourceServer resourceServer) {
Expand Down Expand Up @@ -575,14 +576,26 @@ public Set<String> getScopeAliases(String resourceType, Scope scope) {
return aliases;
}

public static FGAPAdminUserModel setAdminUser(KeycloakSession session, UserModel user) {
Objects.requireNonNull(session.getContext().getRealm(), "A realm is required for authorization filtering");
Objects.requireNonNull(user, "An admin user is required for authorization filtering");
FGAPAdminUserModel admin = UnfilteredUserModelInvocationHandler.wrap(session, user);
session.setAttribute(AUTHORIZATION_USER, admin);
return admin;
}

private static UserModel getAdminUser(KeycloakSession session) {
return session == null ? null : session.getAttribute(AUTHORIZATION_USER, UserModel.class);
}

/**
* <p>Disables authorization and evaluation of permissions for realm resource types when executing the given {@code runnable}
* <p>Disables automatic FGAP query filtering when executing the given {@code runnable}
* in the context of the given {@code session}.
*
* <p>This method should be used whenever a code block should be executed without any evaluation or filtering based on
* the permissions set to a realm. For instance, when caching realm resources where access enforcement does not apply.
* <p>Use this for internal model lookups, for instance when caching realm resources. Explicit permission checks
* are unaffected. Streams and other lazy results must be consumed within the runnable.
*
* @param session the session. If {@code null}, authorization is enabled when executing the code block
* @param session the session. If {@code null}, the runnable is executed directly
* @param runnable the runnable to execute
*/
public static void runWithoutAuthorization(KeycloakSession session, Runnable runnable) {
Expand All @@ -600,7 +613,7 @@ public static void runWithoutAuthorization(KeycloakSession session, Runnable run
}

/**
* <p>Disables authorization and evaluation of permissions for realm resource types when executing the given {@code supplier}
* <p>Disables automatic FGAP query filtering when executing the given {@code supplier}
* in the context of the given {@code session}, returning the supplier's result.
*
* @param session the session. If {@code null}, the supplier is executed directly without modifying authorization state
Expand All @@ -623,11 +636,11 @@ public static <T> T runWithoutAuthorization(KeycloakSession session, Supplier<T>
}

/**
* Returns if authorization is disabled in the context of the given {@code session} at the moment that this method is called.
* Returns whether FGAP v2 partial evaluation is disabled by the realm configuration or temporarily suppressed.
* Filtering also requires an admin bound to the session by {@link #setAdminUser(KeycloakSession, UserModel)}.
*
* @param session the session
* @return {@code true} if authorization is disabled. Otherwise, returns {@code false}.
* Otherwise, {@code false}.
* @return {@code true} if partial evaluation must be skipped
* @see AdminPermissionsSchema#runWithoutAuthorization(KeycloakSession, Runnable)
*/
public static boolean isSkipEvaluation(KeycloakSession session) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2026 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.authorization.fgap;

import org.keycloak.models.UserModel;

/**
* Marks the authenticated admin of a management request. Operations on this {@link UserModel} are never subject to
* FGAP query filtering.
*
* @see AdminPermissionsSchema#setAdminUser(org.keycloak.models.KeycloakSession, UserModel)
*/
public interface FGAPAdminUserModel extends UserModel {
}
Loading
Loading