Organization Groups - Identity Provider Mappers (#46592)

Closes #45512

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis
2026-02-26 13:44:12 +01:00
committed by GitHub
parent 8705ad3c56
commit b7bef85f91
8 changed files with 656 additions and 49 deletions
@@ -17,12 +17,18 @@
package org.keycloak.admin.client.resource;
import java.util.List;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
public interface OrganizationIdentityProviderResource {
@@ -33,4 +39,15 @@ public interface OrganizationIdentityProviderResource {
@DELETE
Response delete();
@Path("groups")
@GET
@Produces(MediaType.APPLICATION_JSON)
List<GroupRepresentation> getGroups(@QueryParam("search") String search,
@QueryParam("q") String searchQuery,
@QueryParam("exact") @DefaultValue("false") Boolean exact,
@QueryParam("first") Integer first,
@QueryParam("max") Integer max,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation,
@QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount);
}
@@ -47,6 +47,8 @@ import jakarta.transaction.Transaction;
import org.keycloak.Config;
import org.keycloak.Config.Scope;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.broker.social.SocialIdentityProviderFactory;
import org.keycloak.cache.AlternativeLookupProvider;
@@ -70,6 +72,7 @@ import org.keycloak.models.Constants;
import org.keycloak.models.GroupModel;
import org.keycloak.models.GroupProvider;
import org.keycloak.models.GroupProviderFactory;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
@@ -950,6 +953,78 @@ public final class KeycloakModelUtils {
return getGroupModel(session, realm, orgInternalGroup, split, 0);
}
/**
* Validates and retrieves the organization for an Identity Provider mapper.
* This performs all necessary checks to ensure the IdP-organization relationship is valid:
* - Organizations feature is enabled
* - Organization exists and is enabled
* - Bidirectional link exists (organization still has this IdP)
*
* @param session the Keycloak session
* @param idpModel the identity provider model
* @return the validated organization if all checks pass, null otherwise
*/
public static OrganizationModel getOrganizationForIdpMapper(KeycloakSession session, IdentityProviderModel idpModel) {
String idpOrgId = idpModel.getOrganizationId();
if (idpOrgId == null) {
return null;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider != null && orgProvider.isEnabled()) {
OrganizationModel organization = orgProvider.getById(idpOrgId);
if (organization != null && organization.isEnabled() && organization.getIdentityProviders().anyMatch(idp -> idp.getAlias().equals(idpModel.getAlias()))) {
return organization;
}
}
logger.warnf("Cannot obtain organization '%s' linked to IdP '%s'", idpModel.getAlias(), idpOrgId);
return null;
}
/**
* Retrieves and validates a group for use in an Identity Provider mapper.
* This method handles organization-aware group lookup.
*
* When the IdP is linked to an organization, this method first attempts to find the group
* within that organization's groups. If not found (or IdP not linked to org), it falls back
* to searching realm groups.
*
* @param session the Keycloak session
* @param realm the realm
* @param mapperModel the mapper model configuration containing the group path
* @param context the brokered identity context containing the IdP configuration
* @return the group if found and valid, null otherwise (mapper should be skipped)
*/
public static GroupModel getGroupForIdpMapper(KeycloakSession session,
RealmModel realm,
IdentityProviderMapperModel mapperModel,
BrokeredIdentityContext context) {
String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP);
GroupModel group = null;
// Check if IdP is linked to organization and validate the relationship
OrganizationModel organization = getOrganizationForIdpMapper(session, context.getIdpConfig());
if (organization != null) {
group = findGroupByPath(session, realm, organization, groupPath);
}
// If not found in organization (or IdP not in org context), try as realm group
if (group == null) {
group = findGroupByPath(session, realm, groupPath);
}
if (group == null) {
logger.warnf("Unable to find group by path '%s' referenced by mapper '%s' on realm '%s'.", groupPath, mapperModel.getName(), realm.getName());
return null;
}
return group;
}
public static Stream<RoleModel> getClientScopeMappingsStream(ClientModel client, ScopeContainerModel container) {
return container.getScopeMappingsStream()
.filter(role -> role.getContainer() instanceof ClientModel &&
@@ -18,7 +18,6 @@
package org.keycloak.broker.oidc.mappers;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.KeycloakSession;
@@ -26,22 +25,17 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.jboss.logging.Logger;
/**
* @author <a href="mailto:artur.baltabayev@bosch.io">Artur Baltabayev</a>,
* <a href="mailto:daniel.fesenmeyer@bosch.io">Daniel Fesenmeyer</a>
*/
public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper {
private static final Logger LOG = Logger.getLogger(AbstractClaimToGroupMapper.class);
@Override
public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user,
IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
GroupModel group = this.getGroup(session, realm, mapperModel);
GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context);
if (group == null) {
return;
}
@@ -55,7 +49,7 @@ public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper {
public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user,
IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
GroupModel group = this.getGroup(session, realm, mapperModel);
GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context);
if (group == null) {
return;
}
@@ -82,16 +76,4 @@ public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper {
protected abstract boolean applies(final IdentityProviderMapperModel mapperModel,
final BrokeredIdentityContext context);
private GroupModel getGroup(KeycloakSession session, final RealmModel realm, final IdentityProviderMapperModel mapperModel) {
String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP);
GroupModel group = KeycloakModelUtils.findGroupByPath(session, realm, groupPath);
if (group == null) {
LOG.warnf("Unable to find group by path '%s' referenced by mapper '%s' on realm '%s'.", groupPath,
mapperModel.getName(), realm.getName());
}
return group;
}
}
@@ -18,7 +18,6 @@ package org.keycloak.broker.saml.mappers;
import org.keycloak.broker.provider.AbstractIdentityProviderMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.KeycloakSession;
@@ -26,8 +25,6 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.jboss.logging.Logger;
/**
* Abstract class that handles the logic for importing and updating brokered users for all mappers that map a SAML
* attribute into a {@code Keycloak} group.
@@ -36,12 +33,9 @@ import org.jboss.logging.Logger;
*/
public abstract class AbstractAttributeToGroupMapper extends AbstractIdentityProviderMapper {
private static final Logger LOG = Logger.getLogger(AbstractAttributeToGroupMapper.class);
@Override
public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
GroupModel group = this.getGroup(session, realm, mapperModel);
GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context);
if (group == null) {
return;
}
@@ -53,7 +47,7 @@ public abstract class AbstractAttributeToGroupMapper extends AbstractIdentityPro
@Override
public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
GroupModel group = this.getGroup(session, realm, mapperModel);
GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context);
if (group == null) {
return;
}
@@ -79,25 +73,4 @@ public abstract class AbstractAttributeToGroupMapper extends AbstractIdentityPro
*/
protected abstract boolean applies(final IdentityProviderMapperModel mapperModel, final BrokeredIdentityContext context);
/**
* Obtains the {@link GroupModel} corresponding the group configured in the specified
* {@link IdentityProviderMapperModel}.
* If the group doesn't correspond to one of the realm's client group or to one of the realm's group, this method
* returns {@code null}.
*
* @param session
* @param realm a reference to the realm.
* @param mapperModel a reference to the {@link IdentityProviderMapperModel} containing the configured group.
* @return the {@link GroupModel} that corresponds to the mapper model group or {@code null}, if the group could not be found
*/
private GroupModel getGroup(KeycloakSession session, final RealmModel realm, final IdentityProviderMapperModel mapperModel) {
String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP);
GroupModel group = KeycloakModelUtils.findGroupByPath(session, realm, groupPath);
if (group == null) {
LOG.warnf("Unable to find group by path '%s' referenced by mapper '%s' on realm '%s'.", groupPath, mapperModel.getName(), realm.getName());
}
return group;
}
}
@@ -21,11 +21,13 @@ import java.util.stream.Stream;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
@@ -38,6 +40,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.StripSecretsUtils;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
@@ -48,6 +51,7 @@ import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
@@ -61,12 +65,14 @@ public class OrganizationIdentityProvidersResource {
private final KeycloakSession session;
private final OrganizationProvider organizationProvider;
private final OrganizationModel organization;
private final AdminEventBuilder adminEvent;
public OrganizationIdentityProvidersResource(KeycloakSession session, OrganizationModel organization, AdminEventBuilder adminEvent) {
this.realm = session == null ? null : session.getContext().getRealm();
this.session = session;
this.organizationProvider = session == null ? null : session.getProvider(OrganizationProvider.class);
this.organization = organization;
this.adminEvent = adminEvent;
}
@POST
@@ -137,6 +143,51 @@ public class OrganizationIdentityProvidersResource {
return toRepresentation(broker);
}
/**
* Returns organization groups for the identity provider with the specified alias.
* It allows filtering and displaying only the organization groups that are valid for the given identity provider.
*
* Only returns groups if the identity provider is associated with the organization and the organization
* is enabled. Otherwise, returns an error or empty stream.
*
* @param alias the identity provider alias
* @param search a string to search for in group names
* @param searchQuery a query to search for group attributes, in the format 'key1:value1 key2:value2'
* @param exact if true, perform exact match on the search parameter
* @param first the position of the first result (pagination offset)
* @param max the maximum number of results to return
* @param briefRepresentation if true, return brief group representation; otherwise return full representation
* @return a stream of organization groups associated with the organization
*/
@Path("{alias}/groups")
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
@Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS)
@Operation(summary = "Returns organization groups for the identity provider",
description = "Returns organization groups that can be used in identity provider mappers. " +
"Only returns groups if the identity provider is associated with the organization.")
@APIResponses(value = {
@APIResponse(responseCode = "200", description = "", content = @Content(schema = @Schema(implementation = GroupRepresentation.class, type = SchemaType.ARRAY))),
@APIResponse(responseCode = "404", description = "Not Found")
})
public Stream<GroupRepresentation> getGroups(
@Parameter(description = "The alias of the identity provider") @PathParam("alias") String alias,
@Parameter(description = "A string to search for in group names") @QueryParam("search") String search,
@Parameter(description = "A query to search for group attributes, in the format 'key1:value1 key2:value2'") @QueryParam("q") String searchQuery,
@Parameter(description = "If true, perform exact match on the search parameter") @QueryParam("exact") @DefaultValue("false") Boolean exact,
@Parameter(description = "The position of the first result (pagination offset)") @QueryParam("first") Integer first,
@Parameter(description = "The maximum number of results to return") @QueryParam("max") Integer max,
@Parameter(description = "If true, return brief representation; otherwise return full representation") @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation,
@Parameter(description = "If true, include subgroups count in the response") @QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount) {
// Validate that the identity provider is associated with the organization
getIdentityProvider(alias);
OrganizationGroupsResource groupsResource = new OrganizationGroupsResource(session, organization, adminEvent);
return groupsResource.getGroups(search, searchQuery, exact, first, max, briefRepresentation, subGroupsCount);
}
@Path("{alias}")
@DELETE
@Produces(MediaType.APPLICATION_JSON)
@@ -348,6 +348,7 @@ public class IdentityProviderResource {
}
IdentityProviderMapperModel model = RepresentationToModel.toModel(mapper);
try {
// model = realm.addIdentityProviderMapper(model);
model = session.identityProviders().createMapper(model);
@@ -408,6 +409,7 @@ public class IdentityProviderResource {
IdentityProviderMapperModel model = session.identityProviders().getMapperById(id);
if (model == null) throw new NotFoundException("Model not found");
model = RepresentationToModel.toModel(rep);
session.identityProviders().updateMapper(model);
adminEvent.operation(OperationType.UPDATE).resource(ResourceType.IDENTITY_PROVIDER_MAPPER).resourcePath(session.getContext().getUri()).representation(rep).success();
@@ -518,4 +520,5 @@ public class IdentityProviderResource {
IdentityProvider<?> provider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias());
return provider.reloadKeys();
}
}
@@ -0,0 +1,387 @@
/*
* 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.testsuite.organization.mapper;
import java.util.List;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.broker.oidc.mappers.AdvancedClaimToGroupMapper;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.broker.provider.HardcodedGroupMapper;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import com.google.common.collect.ImmutableMap;
import org.junit.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.fail;
public class OrganizationGroupOidcIdpMapperTest extends AbstractOrganizationTest {
@Test
public void testAdvancedClaimToGroupMapperWithOrganizationGroup() {
// Create organization with IdP
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
// Create organization group
GroupRepresentation orgGroup = new GroupRepresentation();
orgGroup.setName("test-org-group");
String groupId;
try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
groupId = ApiUtil.getCreatedId(response);
}
GroupRepresentation createdGroup = orgResource.groups().group(groupId).toRepresentation(false);
String groupPath = createdGroup.getPath();
// Create AdvancedClaimToGroupMapper with organization group
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("org-group-mapper");
mapper.setIdentityProviderMapper(AdvancedClaimToGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, groupPath)
.put(AdvancedClaimToGroupMapper.CLAIM, "organization")
.put(AdvancedClaimToGroupMapper.CLAIM_VALUE, orgRep.getName())
.build());
String mapperId;
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
mapperId = ApiUtil.getCreatedId(response);
}
// Verify mapper was created
IdentityProviderMapperRepresentation createdMapper = testRealm().identityProviders()
.get(idp.getAlias())
.getMapperById(mapperId);
assertNotNull("Mapper should be created", createdMapper);
assertEquals("Mapper should reference org group", groupPath, createdMapper.getConfig().get(ConfigConstants.GROUP));
}
@Test
public void testCreateMapperWithOrganizationSubgroup() {
// Create organization with IdP
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
// Create parent organization group
GroupRepresentation parentGroup = new GroupRepresentation();
parentGroup.setName("parent-group");
String parentId;
try (Response response = orgResource.groups().addTopLevelGroup(parentGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
parentId = ApiUtil.getCreatedId(response);
}
// Create child subgroup
GroupRepresentation childGroup = new GroupRepresentation();
childGroup.setName("child-group");
try (Response response = orgResource.groups().group(parentId).addSubGroup(childGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// Get the subgroup from the parent's children
List<GroupRepresentation> children = orgResource.groups().group(parentId).getSubGroups(null, null, null, null);
assertNotNull("Parent should have subgroups", children);
assertThat("Parent should have 1 subgroup", children.size(), is(1));
String childGroupPath = children.get(0).getPath();
// Create mapper with child subgroup
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("subgroup-mapper");
mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, childGroupPath)
.build());
String mapperId;
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
mapperId = ApiUtil.getCreatedId(response);
}
// Verify mapper was created with subgroup path
IdentityProviderMapperRepresentation createdMapper = testRealm().identityProviders()
.get(idp.getAlias())
.getMapperById(mapperId);
assertNotNull("Mapper should be created", createdMapper);
assertEquals("Mapper should reference org subgroup", childGroupPath, createdMapper.getConfig().get(ConfigConstants.GROUP));
}
@Test
public void testGetGroupsEndpointForNonOrganizationIdp() {
// Create IdP NOT linked to organization
IdentityProviderRepresentation nonOrgIdp = bc.setUpIdentityProvider();
nonOrgIdp.setAlias("non-org-idp");
try (Response response = testRealm().identityProviders().create(nonOrgIdp)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
getCleanup().addCleanup(() -> testRealm().identityProviders().get("non-org-idp").remove());
// Create organization with groups
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
GroupRepresentation orgGroup = new GroupRepresentation();
orgGroup.setName("test-org-group");
try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// Try to get groups for non-org IdP - should return NOT_FOUND
try {
testRealm().organizations().get(orgRep.getId())
.identityProviders().get("non-org-idp").getGroups(null, null, false, null, null, true, false);
fail("Should have failed with NotFoundException");
} catch (jakarta.ws.rs.NotFoundException e) {
// Expected
}
}
@Test
public void testUserAddedToOrganizationGroupViaMapper() {
// Create organization with group
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
GroupRepresentation orgGroup = new GroupRepresentation();
orgGroup.setName("mapper-test-group");
String groupId;
try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
groupId = ApiUtil.getCreatedId(response);
}
String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath();
// Add hardcoded group mapper to the organization IdP
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("org-group-mapper");
mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, groupPath)
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// Authenticate via IdP - user should be added to org group
assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail());
// Verify user is member of the organization group
UserRepresentation user = getUserRepresentation(bc.getUserEmail());
assertNotNull(user);
List<MemberRepresentation> groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(1));
assertThat(groupMembers.get(0).getId(), is(user.getId()));
}
@Test
public void testUserNotAddedToGroupAfterIdpUnlinkedFromOrganization() {
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
GroupRepresentation orgGroup = new GroupRepresentation();
orgGroup.setName("unlink-test-group");
String groupId;
try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
groupId = ApiUtil.getCreatedId(response);
}
String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath();
// Add a HardcodedGroupMapper pointing to the org group
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("unlink-test-mapper");
mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, groupPath)
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// First login: user IS added to the org group while IdP is still linked
assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail());
List<MemberRepresentation> groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(1));
// Log out from both realms
UserRepresentation user = getUserRepresentation(bc.getUserEmail());
realmsResouce().realm(bc.consumerRealmName()).users().get(user.getId()).logout();
realmsResouce().realm(bc.providerRealmName()).logoutAll();
// Unlink IdP from organization - the IdP still exists in the realm but is no longer org-linked
try (Response response = orgResource.identityProviders().get(bc.getIDPAlias()).delete()) {
assertThat(response.getStatus(), is(Status.NO_CONTENT.getStatusCode()));
}
// Remove the user from the group so the second login can prove the mapper does not re-add them
orgResource.groups().group(groupId).removeMember(user.getId());
groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(0));
// Second login: bypass the org identity-first page (which hides the unlinked IdP) by
// navigating directly with kc_idp_hint. The IdP still exists in the realm so login succeeds,
// but the mapper cannot resolve the org group and the user is NOT re-added to it.
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
driver.navigate().to(driver.getCurrentUrl() + "&kc_idp_hint=" + bc.getIDPAlias());
loginOrgIdp(bc.getUserLogin(), bc.getUserEmail(), false, true);
// Verify user was not re-added to the org group
groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(0));
}
@Test
public void testRealmGroupAllowedWithOrganizationIdp() {
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
// Create REALM group in the consumer realm
GroupRepresentation realmGroup = new GroupRepresentation();
realmGroup.setName("realm-test-group");
try (Response response = realmsResouce().realm(bc.consumerRealmName()).groups().add(realmGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
String groupPath = realmsResouce().realm(bc.consumerRealmName()).getGroupByPath("/realm-test-group").getPath();
// Add mapper with REALM group to organization IdP
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("realm-group-mapper");
mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, groupPath)
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// Authenticate via IdP - realm groups are always allowed
assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail());
// Verify user is member of the realm group
UserRepresentation user = getUserRepresentation(bc.getUserEmail());
assertNotNull(user);
List<GroupRepresentation> userGroups = realmsResouce().realm(bc.consumerRealmName()).users().get(user.getId()).groups();
assertThat(userGroups, hasSize(1));
assertThat(userGroups.get(0).getPath(), is(groupPath));
}
@Test
public void testHardcodedGroupMapperDoesNotAssignOrganizationGroupMembershipWhenOrganizationIsDisabled() {
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
GroupRepresentation orgGroup = new GroupRepresentation();
orgGroup.setName("disabled-org-test-group");
String groupId;
try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
groupId = ApiUtil.getCreatedId(response);
}
String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath();
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("disabled-org-test-mapper");
mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString())
.put(ConfigConstants.GROUP, groupPath)
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// First login: org is enabled, user IS added to org group
assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail());
List<MemberRepresentation> groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(1));
// When org is disabled, the IdP appears disabled, blocking further broker logins.
orgRep.setEnabled(false);
try (Response ignored = orgResource.update(orgRep)) {
assertThat(ignored.getStatus(), is(Status.NO_CONTENT.getStatusCode()));
}
// Verify the org-linked IdP now appears disabled (org-aware wrapper)
IdentityProviderRepresentation updatedIdp = testRealm().identityProviders().get(bc.getIDPAlias()).toRepresentation();
assertThat("IdP should appear disabled when org is disabled", updatedIdp.isEnabled(), is(false));
// Group membership assigned while the org was enabled is unaffected by the org being disabled
groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(1));
}
}
@@ -0,0 +1,119 @@
/*
* 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.testsuite.organization.mapper;
import java.util.List;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.broker.provider.HardcodedGroupMapper;
import org.keycloak.broker.saml.SAMLIdentityProviderConfig;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.broker.BrokerConfiguration;
import org.keycloak.testsuite.broker.KcSamlBrokerConfiguration;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import com.google.common.collect.ImmutableMap;
import org.junit.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertNotNull;
public class OrganizationGroupSamlIdpMapperTest extends AbstractOrganizationTest {
@Override
protected BrokerConfiguration createBrokerConfiguration() {
return new KcSamlBrokerConfiguration() {
@Override
public RealmRepresentation createProviderRealm() {
RealmRepresentation realmRep = super.createProviderRealm();
realmRep.setOrganizationsEnabled(true);
return realmRep;
}
@Override
public String getIDPClientIdInProviderRealm() {
return "saml-broker";
}
@Override
public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation broker = super.setUpIdentityProvider(syncMode);
broker.getConfig().put(SAMLIdentityProviderConfig.ENTITY_ID, getIDPClientIdInProviderRealm());
return broker;
}
};
}
@Test
public void testHardcodedGroupMapperAssignsOrganizationGroupMembershipWithSamlIdp() {
OrganizationRepresentation orgRep = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId());
GroupRepresentation orgGroup = new GroupRepresentation();
orgGroup.setName("saml-test-group");
String groupId;
try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
groupId = ApiUtil.getCreatedId(response);
}
String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath();
// Create HardcodedGroupMapper pointing to the org group
IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation();
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("saml-hardcoded-group-mapper");
mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID);
mapper.setIdentityProviderAlias(idp.getAlias());
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString())
.put(ConfigConstants.GROUP, groupPath)
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
// Authenticate via SAML IdP - user should be added to the org group via the mapper
assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail());
UserRepresentation user = getUserRepresentation(bc.getUserEmail());
assertNotNull(user);
List<MemberRepresentation> groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false);
assertThat(groupMembers, hasSize(1));
assertThat(groupMembers.get(0).getId(), is(user.getId()));
}
}