Add view-realm admin role check to SCIM discovery endpoints

Closes #46859

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik
2026-03-17 13:31:40 +01:00
committed by Pedro Igor
parent 71385f2df3
commit a4796fe801
9 changed files with 72 additions and 15 deletions
@@ -76,10 +76,10 @@ public interface ScimResourceTypeProvider<R extends ResourceTypeRepresentation>
* and should persist the updated resource and return the persisted instance.
* The returned resource will be used in the response to the client.
*
* @param user the resource to update
* @param resource the resource to update
* @return the updated resource
*/
R update(R user);
R update(R resource);
/**
* Retrieves a resource of this type by its identifier. This method is invoked when a client requests a specific resource,
@@ -1,9 +1,14 @@
package org.keycloak.scim.model.config;
import java.util.List;
import java.util.stream.Stream;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.Model;
import org.keycloak.scim.protocol.ForbiddenException;
import org.keycloak.scim.protocol.request.SearchRequest;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport;
@@ -13,6 +18,12 @@ import org.keycloak.scim.resource.spi.SingletonResourceTypeProvider;
public class ServiceProviderConfigResourceTypeProvider implements SingletonResourceTypeProvider<ServiceProviderConfig> {
private final KeycloakSession session;
public ServiceProviderConfigResourceTypeProvider(KeycloakSession session) {
this.session = session;
}
@Override
public ServiceProviderConfig getSingleton() {
ServiceProviderConfig config = new ServiceProviderConfig();
@@ -30,6 +41,14 @@ public class ServiceProviderConfigResourceTypeProvider implements SingletonResou
return config;
}
@Override
public Stream<ServiceProviderConfig> getAll(SearchRequest searchRequest) {
if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) {
throw new ForbiddenException();
}
return Stream.of(getSingleton());
}
@Override
public Class<ServiceProviderConfig> getResourceType() {
return ServiceProviderConfig.class;
@@ -7,11 +7,9 @@ import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
public class ServiceProviderConfigResourceTypeProviderFactory implements ScimResourceTypeProviderFactory<ServiceProviderConfigResourceTypeProvider> {
public static final ServiceProviderConfigResourceTypeProvider INSTANCE = new ServiceProviderConfigResourceTypeProvider();
@Override
public ServiceProviderConfigResourceTypeProvider create(KeycloakSession session) {
return INSTANCE;
return new ServiceProviderConfigResourceTypeProvider(session);
}
@Override
@@ -5,8 +5,10 @@ import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.Model;
import org.keycloak.scim.protocol.ForbiddenException;
import org.keycloak.scim.protocol.request.SearchRequest;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
@@ -41,7 +43,7 @@ public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceTy
}
@Override
public ResourceType update(ResourceType user) {
public ResourceType update(ResourceType resourceType) {
throw new UnsupportedOperationException("Not supported yet.");
}
@@ -52,6 +54,9 @@ public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceTy
@Override
public Stream<ResourceType> getAll(SearchRequest searchRequest) {
if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) {
throw new ForbiddenException();
}
return session.getKeycloakSessionFactory().getProviderFactoriesStream(ScimResourceTypeProvider.class)
.map(ScimResourceTypeProviderFactory.class::cast)
.map(this::toRepresentation)
@@ -7,12 +7,14 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Stream;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.Model;
import org.keycloak.models.ModelException;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.scim.model.config.ServiceProviderConfigResourceTypeProvider;
import org.keycloak.scim.model.resourcetype.ResourceTypeProviderFactory;
import org.keycloak.scim.protocol.ForbiddenException;
import org.keycloak.scim.protocol.request.SearchRequest;
import org.keycloak.scim.resource.Scim;
import org.keycloak.scim.resource.schema.ModelSchema;
@@ -149,14 +151,17 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
@Override
public Schema get(String id) {
// TODO: Add `view-realm` role check for schema discovery ??
// Currently accessible to any authenticated user with valid bearer token
// Should be aligned with other discovery endpoints (ResourceTypes, ServiceProviderConfig)
if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) {
throw new ForbiddenException();
}
return schemas.get(id);
}
@Override
public Stream<Schema> getAll(SearchRequest searchRequest) {
if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) {
throw new ForbiddenException();
}
// Per RFC 7644 Section 4, /Schemas is a discovery endpoint that SHALL return all schemas.
// Filtering, sorting, and pagination are not supported for discovery endpoints.
// The searchRequest parameter is ignored.
@@ -173,6 +173,21 @@ public class AuthorizationTest extends AbstractScimTest {
noAccessClient.groups().delete(newGroup.getId());
}
@Test
public void testDiscoveryEndpointsDeniedIfRolesNotGranted() {
assertAccessDenied(() -> noAccessClient.config().get());
assertAccessDenied(() -> noAccessClient.schemas().getAll());
assertAccessDenied(() -> noAccessClient.resourceTypes().getAll());
}
@Test
public void testDiscoveryEndpointsAccessIfViewRealmRoleGranted() {
grantAdminRole(AdminRoles.VIEW_REALM);
assertNotNull(noAccessClient.config().get());
assertNotNull(noAccessClient.schemas().getAll());
assertNotNull(noAccessClient.resourceTypes().getAll());
}
@Test
public void testGroupsCanQueryIfQueryRoleGranted() {
createGroup();
@@ -46,9 +46,10 @@ public class ScimClientSupplier implements Supplier<ScimClient, InjectScimClient
UserRepresentation serviceAccountUser = managedRealm.admin().clients().get(id).getServiceAccountUser();
ClientRepresentation realmMgmtClient = managedRealm.admin().clients().findByClientId(Constants.REALM_MANAGEMENT_CLIENT_ID).get(0);
RoleResource manageUsersRole = managedRealm.admin().clients().get(realmMgmtClient.getId()).roles().get(AdminRoles.MANAGE_USERS);
RoleResource viewRealmRole = managedRealm.admin().clients().get(realmMgmtClient.getId()).roles().get(AdminRoles.VIEW_REALM);
managedRealm.admin().users().get(serviceAccountUser.getId()).roles()
.clientLevel(realmMgmtClient.getId())
.add(List.of(manageUsersRole.toRepresentation()));
.add(List.of(manageUsersRole.toRepresentation(), viewRealmRole.toRepresentation()));
}
}
@@ -69,6 +69,8 @@ import org.keycloak.representations.idm.authorization.ScopeRepresentation;
public class AdminPermissionsSchema extends AuthorizationSchema {
public static final String REALMS_RESOURCE_TYPE = "Realms";
public static final String CLIENTS_RESOURCE_TYPE = "Clients";
public static final String GROUPS_RESOURCE_TYPE = "Groups";
public static final String ROLES_RESOURCE_TYPE = "Roles";
@@ -7,16 +7,17 @@ import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.Model;
import org.keycloak.models.Permissions;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.AdminPermissions;
import org.keycloak.services.resources.admin.fgap.GroupPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.RealmPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.UserPermissionEvaluator;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.GROUPS_RESOURCE_TYPE;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.REALMS_RESOURCE_TYPE;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.USERS_RESOURCE_TYPE;
public class DefaultPermissions implements Permissions {
@@ -40,6 +41,7 @@ public class DefaultPermissions implements Permissions {
return switch (realmResourceType) {
case USERS_RESOURCE_TYPE -> evaluateUserPermission(model, scope);
case GROUPS_RESOURCE_TYPE -> evaluateGroupPermission(model, scope);
case REALMS_RESOURCE_TYPE -> evaluateRealmPermission(scope);
default -> false;
};
}
@@ -82,14 +84,24 @@ public class DefaultPermissions implements Permissions {
return false;
}
private RealmModel getRealm() {
return context.getRealm();
private boolean evaluateRealmPermission(String scope) {
Token token = context.getBearerToken();
if (token instanceof AccessToken accessToken) {
AdminPermissionEvaluator evaluator = getEvaluator(accessToken);
RealmPermissionEvaluator realms = evaluator.realm();
if (AdminPermissionsSchema.VIEW.equals(scope)) {
return realms.canViewRealm();
}
}
return false;
}
private AdminPermissionEvaluator getEvaluator(AccessToken accessToken) {
if (realmAuth == null) {
RealmModel realm = getRealm();
realmAuth = AdminPermissions.evaluator(session, realm, new AdminAuth(realm, accessToken, context.getUser(), context.getClient()));
realmAuth = AdminPermissions.evaluator(session, context.getRealm(), new AdminAuth(context.getRealm(), accessToken, context.getUser(), context.getClient()));
}
return realmAuth;
}